From fa2b28b8e32c585096351847b441e80f9695dc5c Mon Sep 17 00:00:00 2001 From: lelo Date: Fri, 27 Feb 2026 09:56:25 +0000 Subject: [PATCH] allow download fullsize images --- .gitignore | 1 + app.py | 79 +++++++++++++++++++++++----- cache_analyzer.py | 9 ++-- example_config_files/app_config.json | 1 + static/app.css | 42 +++++++-------- static/gallery.css | 26 ++++++++- static/gallery.js | 26 ++++++++- templates/app.html | 1 + 8 files changed, 144 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 3043ebc..0a90de6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /filecache /filecache_audio /filecache_image +/filecache_image_full /filecache_video /filecache_other /postgres_data diff --git a/app.py b/app.py index 7bf081d..a86c5c0 100755 --- a/app.py +++ b/app.py @@ -40,10 +40,11 @@ from collections import OrderedDict app_config = auth.return_app_config() BASE_DIR = os.path.realpath(app_config['BASE_DIR']) -cache_audio = diskcache.Cache('./filecache_audio', size_limit= app_config['filecache_size_limit_audio'] * 1024**3) -cache_image = diskcache.Cache('./filecache_image', size_limit= app_config['filecache_size_limit_image'] * 1024**3) -cache_video = diskcache.Cache('./filecache_video', size_limit= app_config['filecache_size_limit_video'] * 1024**3) -cache_other = diskcache.Cache('./filecache_other', size_limit= app_config['filecache_size_limit_other'] * 1024**3) +cache_audio = diskcache.Cache('./filecache_audio', size_limit=app_config['filecache_size_limit_audio'] * 1024**3) +cache_image = diskcache.Cache('./filecache_image', size_limit=app_config['filecache_size_limit_image'] * 1024**3) +cache_image_full = diskcache.Cache('./filecache_image_full', size_limit=app_config.get('filecache_size_limit_image_full', app_config['filecache_size_limit_image']) * 1024**3) +cache_video = diskcache.Cache('./filecache_video', size_limit=app_config['filecache_size_limit_video'] * 1024**3) +cache_other = diskcache.Cache('./filecache_other', size_limit=app_config['filecache_size_limit_other'] * 1024**3) _logged_request_ids = OrderedDict() _logged_request_ids_lock = threading.Lock() @@ -897,14 +898,56 @@ def serve_file(subpath): # 4) Image and thumbnail handling first if mime.startswith('image/'): - small = request.args.get('thumbnail') == 'true' + small = request.args.get('thumbnail') == 'true' + download = request.args.get('download') == 'true' name, ext = os.path.splitext(subpath) - orig_key = subpath + orig_key = subpath small_key = f"{name}_small{ext}" + + # --- Download path: always serve original full-size file from cache_image_full --- + if download: + try: + with cache_image_full.read(orig_key) as reader: + full_file_path = reader.name + cached_hit = True + except KeyError: + cached_hit = False + try: + with open(full_path, 'rb') as source: + cache_image_full.set(orig_key, source, read=True) + with cache_image_full.read(orig_key) as reader: + full_file_path = reader.name + except Exception as e: + app.logger.error(f"Full-size image cache fill failed for {subpath}: {e}") + abort(503, description="Service temporarily unavailable - cache population failed") + + response = send_file( + full_file_path, + mimetype=mime, + as_attachment=True, + download_name=os.path.basename(orig_key) + ) + response.headers['Cache-Control'] = 'public, max-age=86400' + if do_log: + if not _is_duplicate_request(req_id): + a.log_file_access( + orig_key, + os.path.getsize(full_file_path), + mime, + ip_address, + user_agent, + device_id, + cached_hit, + request.method + ) + _mark_request_logged(req_id) + return response + + # --- Display path (view / thumbnail) --- cache_key = small_key if small else orig_key try: - # Try to read the requested variant + # Try to read the requested display variant with cache.read(cache_key) as reader: file_path = reader.name cached_hit = True @@ -912,11 +955,10 @@ def serve_file(subpath): if small: # do not create when thumbnail requested response = make_response('', 204) return response - - cached_hit = False - # On miss: generate both full-size and small thumb, then cache - with Image.open(full_path) as orig: + cached_hit = False + # On miss: generate both display variants, then cache + with Image.open(full_path) as orig: img = ImageOps.exif_transpose(orig) exif_bytes = None try: @@ -942,16 +984,25 @@ def serve_file(subpath): thumb.save(bio, **save_kwargs) bio.seek(0) cache.set(key, bio, read=True) - # Read back the variant we need + + # Also store the original file in cache_image_full (best-effort, avoids re-reads on later downloads) + try: + if orig_key not in cache_image_full: + with open(full_path, 'rb') as source: + cache_image_full.set(orig_key, source, read=True) + except Exception as e: + app.logger.warning(f"Full-size image cache fill failed for {subpath}: {e}") + + # Read back the display variant we need with cache.read(cache_key) as reader: file_path = reader.name - # Serve the image variant + # Serve the display image variant response = send_file( file_path, mimetype=mime, conditional=True, - as_attachment=(request.args.get('download') == 'true'), + as_attachment=False, download_name=os.path.basename(orig_key) ) response.headers['Content-Disposition'] = 'inline' diff --git a/cache_analyzer.py b/cache_analyzer.py index bf2e37a..24e5829 100644 --- a/cache_analyzer.py +++ b/cache_analyzer.py @@ -31,10 +31,11 @@ from typing import Optional, Dict, Any, List, Tuple # -------- Config (override with env vars if needed) -------- CACHE_DIRS = { - "audio": os.environ.get("FILECACHE_AUDIO", "./filecache_audio"), - "image": os.environ.get("FILECACHE_IMAGE", "./filecache_image"), - "video": os.environ.get("FILECACHE_VIDEO", "./filecache_video"), - "other": os.environ.get("FILECACHE_OTHER", "./filecache_other"), + "audio": os.environ.get("FILECACHE_AUDIO", "./filecache_audio"), + "image": os.environ.get("FILECACHE_IMAGE", "./filecache_image"), + "image_full": os.environ.get("FILECACHE_IMAGE_FULL", "./filecache_image_full"), + "video": os.environ.get("FILECACHE_VIDEO", "./filecache_video"), + "other": os.environ.get("FILECACHE_OTHER", "./filecache_other"), } # Column heuristics (actual names in your DB appear to include: access_time, expire_time, size, key) diff --git a/example_config_files/app_config.json b/example_config_files/app_config.json index 449e0b5..c1a8011 100644 --- a/example_config_files/app_config.json +++ b/example_config_files/app_config.json @@ -9,6 +9,7 @@ "BASE_DIR": "/mnt", "filecache_size_limit_audio": 16, "filecache_size_limit_image": 16, + "filecache_size_limit_image_full": 16, "filecache_size_limit_video": 16, "filecache_size_limit_other": 16 } diff --git a/static/app.css b/static/app.css index 3e37d51..fade374 100644 --- a/static/app.css +++ b/static/app.css @@ -1294,44 +1294,44 @@ footer .audio-player-container { } .btn-primary { - background: linear-gradient(135deg, var(--brand-navy), var(--dark-background)); - border: 1px solid var(--brand-navy); - color: #e5e7eb; - box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); + background: linear-gradient(135deg, var(--brand-sky), #60a5fa); + border: 1px solid var(--brand-sky); + color: #f8fafc; + box-shadow: 0 6px 14px rgba(59, 130, 246, 0.25); } .btn-primary:hover { - background: linear-gradient(135deg, #1b2a44, #0d1524); - border-color: #1b2a44; - box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2); + background: linear-gradient(135deg, #2563eb, var(--brand-sky)); + border-color: #2563eb; + box-shadow: 0 8px 18px rgba(59, 130, 246, 0.3); } .btn-primary:active, .btn-primary:focus { - background: linear-gradient(135deg, #0d1524, #0a1020); - border-color: #0d1524; - box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); - color: #e5e7eb; + background: linear-gradient(135deg, #1d4ed8, #2563eb); + border-color: #1d4ed8; + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35); + color: #f8fafc; } .btn-secondary { - background: linear-gradient(135deg, #1f2e4a, #0f172a); - border: 1px solid #1f2e4a; - color: #e5e7eb; - box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); + background: linear-gradient(135deg, #60a5fa, #3b82f6); + border: 1px solid #3b82f6; + color: #f8fafc; + box-shadow: 0 6px 14px rgba(59, 130, 246, 0.2); } .btn-secondary:hover, .btn-secondary:focus { - background: linear-gradient(135deg, #223456, #111a2f); - border-color: #223456; - box-shadow: 0 8px 16px rgba(15, 23, 42, 0.2); + background: linear-gradient(135deg, #3b82f6, #2563eb); + border-color: #2563eb; + box-shadow: 0 8px 16px rgba(59, 130, 246, 0.28); } .btn-secondary:active { - background: linear-gradient(135deg, #0c1220, #0a0f1a); - border-color: #0c1220; - box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); + background: linear-gradient(135deg, #2563eb, #1d4ed8); + border-color: #1d4ed8; + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.32); } .btn-outline-secondary { diff --git a/static/gallery.css b/static/gallery.css index c27f0f5..2f01c69 100644 --- a/static/gallery.css +++ b/static/gallery.css @@ -3,7 +3,8 @@ .gallery-nav:focus, .gallery-nav:active, #gallery-modal-content, -#gallery-close { +#gallery-close, +#gallery-download { -webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */ outline: none; /* Disable outline on click/focus */ } @@ -115,6 +116,29 @@ font-size: 1rem; } + #gallery-download { + position: fixed; + top: 20px; + left: 64px; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(0, 0, 0, 0.6); + color: #fff; + display: grid; + place-items: center; + cursor: pointer; + z-index: 120; + font-size: 1.25rem; + text-decoration: none; + line-height: 1; + } + + #gallery-download:hover { + background: rgba(40, 40, 40, 0.85); + } + #gallery-info.loading { opacity: 0.65; } diff --git a/static/gallery.js b/static/gallery.js index 878d40c..dd7c4eb 100644 --- a/static/gallery.js +++ b/static/gallery.js @@ -189,6 +189,22 @@ function updateFilenameDisplay(relUrl) { filenameEl.textContent = filename; } +function updateDownloadLink(relUrl) { + const downloadBtn = document.getElementById('gallery-download'); + if (!downloadBtn) { + return; + } + if (!relUrl) { + downloadBtn.removeAttribute('href'); + downloadBtn.removeAttribute('download'); + return; + } + const parts = relUrl.split('/'); + const filename = parts[parts.length - 1] || relUrl; + downloadBtn.href = '/media/' + encodeGalleryPath(relUrl) + '?download=true'; + downloadBtn.setAttribute('download', filename); +} + function updateNextButtonState() { const nextButton = document.querySelector('.gallery-next'); if (!nextButton) { @@ -220,7 +236,7 @@ function showGalleryImage(relUrl) { const currentImage = document.getElementById('gallery-modal-content'); const loader = document.getElementById('loader-container'); const closeBtn = document.getElementById('gallery-close'); - + updateDownloadLink(relUrl); // Set a timeout for showing the loader after 500ms. let loaderTimeout = setTimeout(() => { loader.style.display = 'flex'; @@ -321,6 +337,7 @@ function closeGalleryModal() { document.getElementById('gallery-modal').style.display = 'none'; isGalleryLoading = false; setAutoPlay(false); + updateDownloadLink(null); // remove all images const existingImages = document.querySelectorAll('#gallery-modal img'); existingImages.forEach(img => { @@ -579,6 +596,13 @@ function initGalleryControls() { closeButton.addEventListener('click', closeGalleryModal); } + const downloadButton = document.getElementById('gallery-download'); + if (downloadButton) { + downloadButton.addEventListener('click', function (e) { + e.stopPropagation(); + }); + } + if (infoButton) { infoButton.addEventListener('click', function (e) { e.stopPropagation(); diff --git a/templates/app.html b/templates/app.html index 2d9c303..bc64cdd 100644 --- a/templates/app.html +++ b/templates/app.html @@ -206,6 +206,7 @@