Compare commits

..

2 Commits

Author SHA1 Message Date
236b08518b Merge remote-tracking branch 'origin/development' 2026-02-27 09:58:59 +00:00
fa2b28b8e3 allow download fullsize images 2026-02-27 09:56:25 +00:00
8 changed files with 144 additions and 41 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
/filecache /filecache
/filecache_audio /filecache_audio
/filecache_image /filecache_image
/filecache_image_full
/filecache_video /filecache_video
/filecache_other /filecache_other
/postgres_data /postgres_data

79
app.py
View File

@ -40,10 +40,11 @@ from collections import OrderedDict
app_config = auth.return_app_config() app_config = auth.return_app_config()
BASE_DIR = os.path.realpath(app_config['BASE_DIR']) 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_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 = 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_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_other = diskcache.Cache('./filecache_other', size_limit= app_config['filecache_size_limit_other'] * 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 = OrderedDict()
_logged_request_ids_lock = threading.Lock() _logged_request_ids_lock = threading.Lock()
@ -897,14 +898,56 @@ def serve_file(subpath):
# 4) Image and thumbnail handling first # 4) Image and thumbnail handling first
if mime.startswith('image/'): 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) name, ext = os.path.splitext(subpath)
orig_key = subpath orig_key = subpath
small_key = f"{name}_small{ext}" 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 cache_key = small_key if small else orig_key
try: try:
# Try to read the requested variant # Try to read the requested display variant
with cache.read(cache_key) as reader: with cache.read(cache_key) as reader:
file_path = reader.name file_path = reader.name
cached_hit = True cached_hit = True
@ -912,11 +955,10 @@ def serve_file(subpath):
if small: # do not create when thumbnail requested if small: # do not create when thumbnail requested
response = make_response('', 204) response = make_response('', 204)
return response 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) img = ImageOps.exif_transpose(orig)
exif_bytes = None exif_bytes = None
try: try:
@ -942,16 +984,25 @@ def serve_file(subpath):
thumb.save(bio, **save_kwargs) thumb.save(bio, **save_kwargs)
bio.seek(0) bio.seek(0)
cache.set(key, bio, read=True) 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: with cache.read(cache_key) as reader:
file_path = reader.name file_path = reader.name
# Serve the image variant # Serve the display image variant
response = send_file( response = send_file(
file_path, file_path,
mimetype=mime, mimetype=mime,
conditional=True, conditional=True,
as_attachment=(request.args.get('download') == 'true'), as_attachment=False,
download_name=os.path.basename(orig_key) download_name=os.path.basename(orig_key)
) )
response.headers['Content-Disposition'] = 'inline' response.headers['Content-Disposition'] = 'inline'

View File

@ -31,10 +31,11 @@ from typing import Optional, Dict, Any, List, Tuple
# -------- Config (override with env vars if needed) -------- # -------- Config (override with env vars if needed) --------
CACHE_DIRS = { CACHE_DIRS = {
"audio": os.environ.get("FILECACHE_AUDIO", "./filecache_audio"), "audio": os.environ.get("FILECACHE_AUDIO", "./filecache_audio"),
"image": os.environ.get("FILECACHE_IMAGE", "./filecache_image"), "image": os.environ.get("FILECACHE_IMAGE", "./filecache_image"),
"video": os.environ.get("FILECACHE_VIDEO", "./filecache_video"), "image_full": os.environ.get("FILECACHE_IMAGE_FULL", "./filecache_image_full"),
"other": os.environ.get("FILECACHE_OTHER", "./filecache_other"), "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) # Column heuristics (actual names in your DB appear to include: access_time, expire_time, size, key)

View File

@ -9,6 +9,7 @@
"BASE_DIR": "/mnt", "BASE_DIR": "/mnt",
"filecache_size_limit_audio": 16, "filecache_size_limit_audio": 16,
"filecache_size_limit_image": 16, "filecache_size_limit_image": 16,
"filecache_size_limit_image_full": 16,
"filecache_size_limit_video": 16, "filecache_size_limit_video": 16,
"filecache_size_limit_other": 16 "filecache_size_limit_other": 16
} }

View File

@ -1294,44 +1294,44 @@ footer .audio-player-container {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--brand-navy), var(--dark-background)); background: linear-gradient(135deg, var(--brand-sky), #60a5fa);
border: 1px solid var(--brand-navy); border: 1px solid var(--brand-sky);
color: #e5e7eb; color: #f8fafc;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); box-shadow: 0 6px 14px rgba(59, 130, 246, 0.25);
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(135deg, #1b2a44, #0d1524); background: linear-gradient(135deg, #2563eb, var(--brand-sky));
border-color: #1b2a44; border-color: #2563eb;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2); box-shadow: 0 8px 18px rgba(59, 130, 246, 0.3);
} }
.btn-primary:active, .btn-primary:active,
.btn-primary:focus { .btn-primary:focus {
background: linear-gradient(135deg, #0d1524, #0a1020); background: linear-gradient(135deg, #1d4ed8, #2563eb);
border-color: #0d1524; border-color: #1d4ed8;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35);
color: #e5e7eb; color: #f8fafc;
} }
.btn-secondary { .btn-secondary {
background: linear-gradient(135deg, #1f2e4a, #0f172a); background: linear-gradient(135deg, #60a5fa, #3b82f6);
border: 1px solid #1f2e4a; border: 1px solid #3b82f6;
color: #e5e7eb; color: #f8fafc;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.18); box-shadow: 0 6px 14px rgba(59, 130, 246, 0.2);
} }
.btn-secondary:hover, .btn-secondary:hover,
.btn-secondary:focus { .btn-secondary:focus {
background: linear-gradient(135deg, #223456, #111a2f); background: linear-gradient(135deg, #3b82f6, #2563eb);
border-color: #223456; border-color: #2563eb;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.2); box-shadow: 0 8px 16px rgba(59, 130, 246, 0.28);
} }
.btn-secondary:active { .btn-secondary:active {
background: linear-gradient(135deg, #0c1220, #0a0f1a); background: linear-gradient(135deg, #2563eb, #1d4ed8);
border-color: #0c1220; border-color: #1d4ed8;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); box-shadow: 0 4px 10px rgba(37, 99, 235, 0.32);
} }
.btn-outline-secondary { .btn-outline-secondary {

View File

@ -3,7 +3,8 @@
.gallery-nav:focus, .gallery-nav:focus,
.gallery-nav:active, .gallery-nav:active,
#gallery-modal-content, #gallery-modal-content,
#gallery-close { #gallery-close,
#gallery-download {
-webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */ -webkit-tap-highlight-color: transparent; /* Disable blue highlight on Android/iOS/Chrome/Safari */
outline: none; /* Disable outline on click/focus */ outline: none; /* Disable outline on click/focus */
} }
@ -115,6 +116,29 @@
font-size: 1rem; 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 { #gallery-info.loading {
opacity: 0.65; opacity: 0.65;
} }

View File

@ -189,6 +189,22 @@ function updateFilenameDisplay(relUrl) {
filenameEl.textContent = filename; 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() { function updateNextButtonState() {
const nextButton = document.querySelector('.gallery-next'); const nextButton = document.querySelector('.gallery-next');
if (!nextButton) { if (!nextButton) {
@ -220,7 +236,7 @@ function showGalleryImage(relUrl) {
const currentImage = document.getElementById('gallery-modal-content'); const currentImage = document.getElementById('gallery-modal-content');
const loader = document.getElementById('loader-container'); const loader = document.getElementById('loader-container');
const closeBtn = document.getElementById('gallery-close'); const closeBtn = document.getElementById('gallery-close');
updateDownloadLink(relUrl);
// Set a timeout for showing the loader after 500ms. // Set a timeout for showing the loader after 500ms.
let loaderTimeout = setTimeout(() => { let loaderTimeout = setTimeout(() => {
loader.style.display = 'flex'; loader.style.display = 'flex';
@ -321,6 +337,7 @@ function closeGalleryModal() {
document.getElementById('gallery-modal').style.display = 'none'; document.getElementById('gallery-modal').style.display = 'none';
isGalleryLoading = false; isGalleryLoading = false;
setAutoPlay(false); setAutoPlay(false);
updateDownloadLink(null);
// remove all images // remove all images
const existingImages = document.querySelectorAll('#gallery-modal img'); const existingImages = document.querySelectorAll('#gallery-modal img');
existingImages.forEach(img => { existingImages.forEach(img => {
@ -579,6 +596,13 @@ function initGalleryControls() {
closeButton.addEventListener('click', closeGalleryModal); closeButton.addEventListener('click', closeGalleryModal);
} }
const downloadButton = document.getElementById('gallery-download');
if (downloadButton) {
downloadButton.addEventListener('click', function (e) {
e.stopPropagation();
});
}
if (infoButton) { if (infoButton) {
infoButton.addEventListener('click', function (e) { infoButton.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();

View File

@ -206,6 +206,7 @@
<div id="gallery-modal" style="display: none;"> <div id="gallery-modal" style="display: none;">
<button id="gallery-close">x</button> <button id="gallery-close">x</button>
<button id="gallery-info" aria-expanded="false" aria-controls="gallery-exif">i</button> <button id="gallery-info" aria-expanded="false" aria-controls="gallery-exif">i</button>
<a id="gallery-download" aria-label="Download original image" title="Download original">&#8681;</a>
<img id="gallery-modal-content" src=""> <img id="gallery-modal-content" src="">
<button class="gallery-nav gallery-prev"></button> <button class="gallery-nav gallery-prev"></button>
<button class="gallery-nav gallery-next"></button> <button class="gallery-nav gallery-next"></button>