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 journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=5000") 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) conn.create_function('norm', 1, _norm)
return conn return conn
@ -1212,3 +1215,11 @@ def _migrate(conn_factory):
) )
""") """)
logger.info("Migration: dogs.gewicht_kg aus health synchronisiert.") 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.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from database import init_db from database import init_db
@ -98,6 +99,20 @@ class _CacheControlMiddleware(BaseHTTPMiddleware):
app.add_middleware(_CacheControlMiddleware) 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) # 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: 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: if ext.lower() not in _PREVIEW_EXTS:
return None return None
try: try:
@ -172,7 +172,7 @@ def generate_preview(data: bytes, ext: str) -> bytes | None:
img = img.convert("RGB") img = img.convert("RGB")
img.thumbnail((_PREVIEW_MAX, _PREVIEW_MAX), Image.LANCZOS) img.thumbnail((_PREVIEW_MAX, _PREVIEW_MAX), Image.LANCZOS)
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="JPEG", quality=72, optimize=True) img.save(buf, format="WEBP", quality=80)
return buf.getvalue() return buf.getvalue()
except Exception: except Exception:
return None return None
@ -185,11 +185,11 @@ def preview_url_from(url: str | None) -> str | None:
return None return None
low = url.lower() low = url.lower()
if any(low.endswith(s) for s in ( 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 return None
base, _ = os.path.splitext(url) base, _ = os.path.splitext(url)
return base + "_preview.jpg" return base + "_preview.webp"
def extract_video_thumb(video_path: str) -> str | None: 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) base, ext = os.path.splitext(fname)
if ext.lower() not in _PREVIEW_EXTS: if ext.lower() not in _PREVIEW_EXTS:
continue 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): if os.path.exists(preview_path):
skipped += 1 skipped += 1
continue continue

View file

@ -686,7 +686,7 @@ async def upload_media(dog_id: int, entry_id: int,
elif media_type == "image": elif media_type == "image":
preview_bytes = generate_preview(raw_data, ext) preview_bytes = generate_preview(raw_data, ext)
if preview_bytes: 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: with open(preview_path, "wb") as f:
f.write(preview_bytes) f.write(preview_bytes)

View file

@ -90,7 +90,7 @@ def _save_upload(file: UploadFile, data: bytes) -> str:
else: else:
preview_bytes = generate_preview(data, ext) preview_bytes = generate_preview(data, ext)
if preview_bytes: 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) f.write(preview_bytes)
return f"/media/forum/{filename}" return f"/media/forum/{filename}"

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {

View file

@ -598,7 +598,9 @@ window.Page_diary = (() => {
content.innerHTML = `<div class="diary-media-mosaic">${ content.innerHTML = `<div class="diary-media-mosaic">${
allMedia.map(m => ` allMedia.map(m => `
<div class="diary-mosaic-item" data-entry-id="${m.entryId}" data-full-url="${UI.escape(m.url)}"> <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)}'"> onerror="this.src='${UI.escape(m.url)}'">
</div>`).join('') </div>`).join('')
}</div>`; }</div>`;
@ -839,7 +841,10 @@ window.Page_diary = (() => {
</div>`; </div>`;
} else { } else {
photoHtml = `<div class="diary-card-photo"> 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>` : ''} ${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`; </div>`;
} }

View file

@ -250,7 +250,10 @@ window.Page_forum = (() => {
const fotoHtml = t.foto_preview const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(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>` ? `<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 ` return `

View file

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