Merge remote-tracking branch 'origin/development'

This commit is contained in:
lelo 2026-02-27 09:58:59 +00:00
commit 236b08518b
8 changed files with 144 additions and 41 deletions

1
.gitignore vendored
View File

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

63
app.py
View File

@ -42,6 +42,7 @@ 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_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)
@ -898,13 +899,55 @@ def serve_file(subpath):
# 4) Image and thumbnail handling first
if mime.startswith('image/'):
small = request.args.get('thumbnail') == 'true'
download = request.args.get('download') == 'true'
name, ext = os.path.splitext(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
@ -914,9 +957,8 @@ def serve_file(subpath):
return response
cached_hit = False
# On miss: generate both full-size and small thumb, then cache
# 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'

View File

@ -33,6 +33,7 @@ from typing import Optional, Dict, Any, List, Tuple
CACHE_DIRS = {
"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"),
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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();

View File

@ -206,6 +206,7 @@
<div id="gallery-modal" style="display: none;">
<button id="gallery-close">x</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="">
<button class="gallery-nav gallery-prev"></button>
<button class="gallery-nav gallery-next"></button>