Performance: GZip, Cache-Control, WebP, SQLite-Tuning, Indizes, srcset — SW by-v438, APP_VER 417

This commit is contained in:
rene 2026-04-26 17:40:18 +02:00
parent 5bd07d9598
commit e0c2b2bdc1
10 changed files with 46 additions and 12 deletions

View file

@ -26,6 +26,9 @@ def get_connection() -> sqlite3.Connection:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA cache_size = -32000") # 32MB Page-Cache
conn.execute("PRAGMA temp_store = MEMORY") # Temp-Tabellen im RAM statt auf Disk
conn.execute("PRAGMA mmap_size = 268435456") # 256MB Memory-Mapped I/O
conn.create_function('norm', 1, _norm)
return conn
@ -1212,3 +1215,11 @@ def _migrate(conn_factory):
)
""")
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.")
# Performance-Indizes für häufige Queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_naechstes ON health(naechstes) WHERE naechstes IS NOT NULL")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ki_calls_user_date ON ki_daily_calls(user_id, date)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_events_user_datum ON events(user_id, datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)")
logger.info("Migration: Performance-Indizes bereit.")

View file

@ -10,6 +10,7 @@ from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from contextlib import asynccontextmanager
from database import init_db
@ -98,6 +99,20 @@ class _CacheControlMiddleware(BaseHTTPMiddleware):
app.add_middleware(_CacheControlMiddleware)
class MediaCacheMiddleware(BaseHTTPMiddleware):
"""Setzt aggressive Cache-Header für /media/-Requests.
UUID-basierte Dateinamen ändern sich nie immutable caching.
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith('/media/'):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
app.add_middleware(MediaCacheMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# ------------------------------------------------------------------
# API-Router registrieren (werden nach und nach hinzugefügt)
# ------------------------------------------------------------------

View file

@ -162,7 +162,7 @@ _PREVIEW_MAX = 800 # längste Seite in Pixeln
def generate_preview(data: bytes, ext: str) -> bytes | None:
"""Erzeugt ein JPEG-Preview (max _PREVIEW_MAX px). Gibt None zurück bei Videos/PDFs/Fehler."""
"""Erzeugt ein WebP-Preview (max _PREVIEW_MAX px). Gibt None zurück bei Videos/PDFs/Fehler."""
if ext.lower() not in _PREVIEW_EXTS:
return None
try:
@ -172,7 +172,7 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
img = img.convert("RGB")
img.thumbnail((_PREVIEW_MAX, _PREVIEW_MAX), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=72, optimize=True)
img.save(buf, format="WEBP", quality=80)
return buf.getvalue()
except Exception:
return None
@ -185,11 +185,11 @@ def preview_url_from(url: str | None) -> str | None:
return None
low = url.lower()
if any(low.endswith(s) for s in (
".mp4", ".webm", ".mov", ".avi", ".pdf", "_thumb.jpg", "_preview.jpg"
".mp4", ".webm", ".mov", ".avi", ".pdf", "_thumb.jpg", "_preview.jpg", "_preview.webp"
)):
return None
base, _ = os.path.splitext(url)
return base + "_preview.jpg"
return base + "_preview.webp"
def extract_video_thumb(video_path: str) -> str | None:

View file

@ -829,7 +829,7 @@ async def generate_media_previews(user=Depends(require_admin)):
base, ext = os.path.splitext(fname)
if ext.lower() not in _PREVIEW_EXTS:
continue
preview_path = os.path.join(folder, base + "_preview.jpg")
preview_path = os.path.join(folder, base + "_preview.webp")
if os.path.exists(preview_path):
skipped += 1
continue

View file

@ -686,7 +686,7 @@ async def upload_media(dog_id: int, entry_id: int,
elif media_type == "image":
preview_bytes = generate_preview(raw_data, ext)
if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.jpg"
preview_path = os.path.splitext(path)[0] + "_preview.webp"
with open(preview_path, "wb") as f:
f.write(preview_bytes)

View file

@ -90,7 +90,7 @@ def _save_upload(file: UploadFile, data: bytes) -> str:
else:
preview_bytes = generate_preview(data, ext)
if preview_bytes:
with open(os.path.splitext(path)[0] + "_preview.jpg", "wb") as f:
with open(os.path.splitext(path)[0] + "_preview.webp", "wb") as f:
f.write(preview_bytes)
return f"/media/forum/{filename}"

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '416'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '417'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -598,7 +598,9 @@ window.Page_diary = (() => {
content.innerHTML = `<div class="diary-media-mosaic">${
allMedia.map(m => `
<div class="diary-mosaic-item" data-entry-id="${m.entryId}" data-full-url="${UI.escape(m.url)}">
<img src="${UI.escape(m.preview_url || m.url)}" alt="" loading="lazy"
<img src="${UI.escape(m.preview_url || m.url)}"
${m.preview_url ? `srcset="${UI.escape(m.preview_url)} 800w, ${UI.escape(m.url)} 2000w" sizes="(max-width:400px) 200px, 400px"` : ''}
alt="" loading="lazy"
onerror="this.src='${UI.escape(m.url)}'">
</div>`).join('')
}</div>`;
@ -839,7 +841,10 @@ window.Page_diary = (() => {
</div>`;
} else {
photoHtml = `<div class="diary-card-photo">
<img src="${e.cover_preview_url || e.cover_url || coverMedia.preview_url || coverMedia.url}" alt="Foto" loading="lazy">
<img src="${e.cover_preview_url || e.cover_url || coverMedia.preview_url || coverMedia.url}"
${(e.cover_preview_url && e.cover_url) ? `srcset="${UI.escape(e.cover_preview_url)} 800w, ${UI.escape(e.cover_url)} 2000w" sizes="(max-width:600px) 300px, 600px"` : ''}
alt="Foto" loading="lazy"
${e.cover_url ? `onerror="this.src='${UI.escape(e.cover_url)}'"` : ''}>
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`;
}

View file

@ -250,7 +250,10 @@ window.Page_forum = (() => {
const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview_url || t.foto_preview)}" alt="" loading="lazy" onerror="this.src='${_esc(t.foto_preview)}'">`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy"
onerror="this.src='${_esc(t.foto_preview)}'">`
: '';
return `

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v437';
const CACHE_VERSION = 'by-v438';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten