Merge: promote dev to prod
This commit is contained in:
commit
f077bcf25a
4 changed files with 55 additions and 19 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.28.0
|
0.28.1
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,17 @@ class Base(DeclarativeBase):
|
||||||
pass
|
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)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,15 @@ from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
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 fastapi.responses import FileResponse
|
||||||
from sqlalchemy import and_, func, or_, text
|
from sqlalchemy import and_, func, or_, text
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import sysconfig
|
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.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.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
from app.plex import stream as plex_stream
|
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"})
|
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}")
|
@router.get("/image/{rating_key}")
|
||||||
def image(
|
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
|
||||||
rating_key: str,
|
|
||||||
variant: str = "thumb",
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> Response:
|
|
||||||
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
|
"""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"
|
v = "art" if variant == "art" else "thumb"
|
||||||
cache_key = f"item_{rating_key}_{v}"
|
cache_key = f"item_{rating_key}_{v}"
|
||||||
hit = _img_cache_read(cache_key)
|
hit = _img_cache_read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
return _img_response(*hit)
|
return _img_response(*hit)
|
||||||
|
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
key = _image_key(db, str(rating_key), v)
|
key = _image_key(db, str(rating_key), v)
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="No image")
|
raise HTTPException(status_code=404, detail="No image")
|
||||||
try:
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
with PlexClient(db) as plex:
|
|
||||||
data, ct = plex.image_bytes(key)
|
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
try:
|
||||||
|
with plex:
|
||||||
|
data, ct = plex.image_bytes(key)
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||||
_img_cache_write(cache_key, data, ct)
|
_img_cache_write(cache_key, data, ct)
|
||||||
|
|
@ -665,8 +681,10 @@ def _rich_meta(meta: dict) -> dict:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/person-image")
|
@router.get("/person-image")
|
||||||
def person_image(u: str, _: User = Depends(current_user)) -> Response:
|
def person_image(u: str, request: Request) -> Response:
|
||||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed)."""
|
"""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):
|
if not u.startswith(_PERSON_IMG_HOST):
|
||||||
raise HTTPException(status_code=400, detail="Unsupported image host")
|
raise HTTPException(status_code=400, detail="Unsupported image host")
|
||||||
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.28.0",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue