allow download fullsize images
This commit is contained in:
parent
032acb7664
commit
fa2b28b8e3
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
79
app.py
@ -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'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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">⇩</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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user