fix(i18n): every refusal the API answers is structured and said in the reader's language

~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
This commit is contained in:
2026-09-19 07:34:17 +02:00
parent dc4e8b9fcd
commit 7ae5c57b6b
39 changed files with 716 additions and 378 deletions
+23 -19
View File
@@ -39,6 +39,8 @@ from app.plex import stream as plex_stream
from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -49,7 +51,7 @@ def _require_session(request: Request) -> int:
low-sensitivity; a valid session is enough."""
uid, _ = resolved_user_id(request)
if not uid:
raise HTTPException(status_code=401, detail="Not authenticated")
raise refused(401, "not_authenticated")
return uid
@@ -78,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
with SessionLocal() as db:
key = imgcache.key_for(db, str(rating_key), v)
if not key:
raise HTTPException(status_code=404, detail="No image")
raise not_found("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))
except PlexNotConfigured:
raise refused(400, "plex_not_configured") from None
try:
with plex:
data, ct = plex.image_bytes(key, w, h)
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
# silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
# detail — the 502 is the whole answer, as for the person-image proxy below.
except PlexError:
raise refused(502, "image_fetch_failed") from None
imgcache.write(cache_key, data, ct)
return imgcache.response(data, ct)
@@ -214,7 +218,7 @@ def person_image(u: str, request: Request) -> Response:
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
raise refused(400, "image_host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = imgcache.read(cache_key)
if hit:
@@ -224,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
r = c.get(u)
r.raise_for_status()
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Image fetch failed")
raise refused(502, "image_fetch_failed")
ct = r.headers.get("content-type", "image/jpeg")
imgcache.write(cache_key, r.content, ct)
return imgcache.response(r.content, ct)
@@ -444,7 +448,7 @@ def item_state(
it = item_or_404(db, rating_key)
status = body.status
if status not in ("new", "watched", "hidden"):
raise HTTPException(status_code=400, detail="Invalid status")
raise refused(400, "invalid_status")
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
@@ -546,7 +550,7 @@ def show_state(
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
sh = by_rating_key(db, PlexShow, rating_key)
if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show")
raise not_found("show")
watched = body.watched
eps = (
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
@@ -568,7 +572,7 @@ def season_state(
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
se = by_rating_key(db, PlexSeason, rating_key)
if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season")
raise not_found("season")
watched = body.watched
eps = (
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
@@ -606,10 +610,10 @@ def stream_session(
"start_s": 0.0,
}
if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
raise refused(404, "media_missing")
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
raise refused(404, "media_missing")
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
@@ -640,7 +644,7 @@ def stream_file(
it = item_or_404(db, rating_key)
src = plex_paths.local_media_path(db, it.file_path)
if src is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
raise refused(404, "media_missing")
if download:
# A clean, safe download name from the title + the real file extension.
ext = src.suffix or (f".{it.container}" if it.container else "")
@@ -694,10 +698,10 @@ def stream_hls_file(
or "\\" in filename
or ".." in filename
):
raise HTTPException(status_code=404, detail="Not found")
raise not_found("file")
s = plex_stream.current_session(user.id, str(rating_key))
if s is None or s.id != session_id:
raise HTTPException(status_code=404, detail="No active playback session")
raise refused(404, "session_gone")
path = s.dir / filename
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
@@ -707,14 +711,14 @@ def stream_hls_file(
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
# workers and their DB connections (see `artifact_may_arrive`).
if not plex_stream.artifact_may_arrive(s, filename):
raise HTTPException(status_code=404, detail="Not available yet")
raise refused(404, "not_ready")
timeout = 30 if ext == ".m3u8" else 20
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
# past the produced region was enough to do it (round 5).
if not plex_stream.wait_for(path, timeout, proc=s.proc):
raise HTTPException(status_code=404, detail="Not available yet")
raise refused(404, "not_ready")
try:
content = path.read_bytes()
except OSError:
@@ -725,7 +729,7 @@ def stream_hls_file(
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
# instance's in-flight segment request races the rmtree on every seek-restart.
raise HTTPException(status_code=404, detail="No active playback session") from None
raise refused(404, "session_gone") from None
return Response(
content=content,
media_type=_HLS_CT[ext],