diff --git a/VERSION b/VERSION index 022a033..48f7a71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 \ No newline at end of file +0.28.1 diff --git a/backend/app/db.py b/backend/app/db.py index 0e79de9..fb9b087 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -10,7 +10,17 @@ class Base(DeclarativeBase): pass -engine = create_engine(settings.database_url, pool_pre_ping=True, future=True) +# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on +# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch +# (see routes/plex.py), so this headroom is only for the many brief sessions, not long ones. +engine = create_engine( + settings.database_url, + pool_pre_ping=True, + pool_size=20, + max_overflow=30, + pool_timeout=30, + future=True, +) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index d7b6f81..ff8db26 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -12,15 +12,15 @@ from pathlib import Path from urllib.parse import quote import httpx -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import FileResponse from sqlalchemy import and_, func, or_, text from sqlalchemy.orm import Session, aliased from app import sysconfig -from app.auth import current_user +from app.auth import current_user, resolved_user_id from app.config import settings -from app.db import get_db +from app.db import SessionLocal, get_db from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User from app.plex import paths as plex_paths from app.plex import stream as plex_stream @@ -562,28 +562,44 @@ def _img_response(data: bytes, ct: str) -> Response: return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}) +def _require_session(request: Request) -> int: + """Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT + a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are + low-sensitivity; a valid session is enough.""" + uid, _ = resolved_user_id(request) + if not uid: + raise HTTPException(status_code=401, detail="Not authenticated") + return uid + + @router.get("/image/{rating_key}") -def image( - rating_key: str, - variant: str = "thumb", - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> Response: +def image(rating_key: str, request: Request, variant: str = "thumb") -> Response: """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A - thin on-disk cache serves repeat views without re-hitting Plex.""" + thin on-disk cache serves repeat views without re-hitting Plex. + + ⚠️ This endpoint is called in BURSTS (a collections page fires 100+ concurrent poster requests), + so it must NOT hold a DB connection during the slow Plex fetch — doing so exhausted the pool + (QueuePool timeout, broken images on prod). Warm path (cache hit) touches NO DB; cold path opens a + brief session only to resolve the image key + read the Plex config, then RELEASES it before the + fetch. Auth is the signed-session check (no DB user-load).""" + _require_session(request) v = "art" if variant == "art" else "thumb" cache_key = f"item_{rating_key}_{v}" hit = _img_cache_read(cache_key) if hit: return _img_response(*hit) - key = _image_key(db, str(rating_key), v) - if not key: - raise HTTPException(status_code=404, detail="No image") + # Cold miss: short-lived session for the key + Plex client config, released before the slow fetch. try: - with PlexClient(db) as plex: - data, ct = plex.image_bytes(key) + with SessionLocal() as db: + key = _image_key(db, str(rating_key), v) + if not key: + raise HTTPException(status_code=404, detail="No image") + plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) + try: + with plex: + data, ct = plex.image_bytes(key) except PlexError as e: raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}") _img_cache_write(cache_key, data, ct) @@ -665,8 +681,10 @@ def _rich_meta(meta: dict) -> dict: @router.get("/person-image") -def person_image(u: str, _: User = Depends(current_user)) -> Response: - """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).""" +def person_image(u: str, request: Request) -> Response: + """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed). + Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch.""" + _require_session(request) if not u.startswith(_PERSON_IMG_HOST): raise HTTPException(status_code=400, detail="Unsupported image host") cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest() diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index f94d6ce..0b00e5e 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,14 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.28.1", + date: "2026-07-06", + summary: "Fix: slow / broken Plex poster images on collection-heavy pages.", + features: [ + "Plex images: fixed the poster/art proxy exhausting the database connection pool when a page fired many poster requests at once (the culprit behind slow, partially-loaded collection grids) — image requests no longer hold a database connection while fetching from Plex, and repeat views are served straight from the disk cache.", + ], + }, { version: "0.28.0", date: "2026-07-06",