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
+6 -5
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session
@@ -19,7 +19,8 @@ from app.models import (
)
from app.plex.cards import leaf_card
from app.plex.lookups import by_rating_key
from app.routes._common import owned_or_404
from app.routes._common import owned_or_404, not_found
from app.refusal import refused
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -116,7 +117,7 @@ def create_playlist(
) -> dict:
title = body.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
raise refused(400, "title_required")
pl = PlexPlaylist(user_id=user.id, title=title)
db.add(pl)
db.flush()
@@ -182,7 +183,7 @@ def rename_playlist(
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
title = body.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
raise refused(400, "title_required")
pl.title = title
db.commit()
count = (
@@ -219,7 +220,7 @@ def playlist_add_item(
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
it = by_rating_key(db, PlexItem, body.item_rating_key)
if it is None:
raise HTTPException(status_code=404, detail="Unknown item")
raise not_found("item")
exists = (
db.execute(
select(PlexPlaylistItem).where(