From 7ae5c57b6b8d7d124b6f9660b8fbd2274bb01f27 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 19 Sep 2026 07:34:17 +0200 Subject: [PATCH] fix(i18n): every refusal the API answers is structured and said in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~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. 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. --- backend/app/auth.py | 14 +-- backend/app/refusal.py | 37 ++++++ backend/app/routes/_common.py | 12 +- backend/app/routes/admin.py | 36 +++--- backend/app/routes/auth.py | 21 ++-- backend/app/routes/channels.py | 17 +-- backend/app/routes/config.py | 24 ++-- backend/app/routes/discover.py | 8 +- backend/app/routes/downloads.py | 51 ++++---- backend/app/routes/feed.py | 13 ++- backend/app/routes/me.py | 21 ++-- backend/app/routes/messages.py | 28 ++--- backend/app/routes/plex/_shared.py | 4 +- backend/app/routes/plex/admin.py | 22 ++-- backend/app/routes/plex/collections.py | 41 +++---- backend/app/routes/plex/detail.py | 5 +- backend/app/routes/plex/playback.py | 42 ++++--- backend/app/routes/plex/playlists.py | 11 +- backend/app/routes/public.py | 24 ++-- backend/app/routes/saved_views.py | 13 ++- backend/app/routes/scheduler.py | 15 ++- backend/app/routes/search.py | 31 +++-- backend/app/routes/setup.py | 26 ++--- backend/app/routes/sync.py | 6 +- backend/app/routes/tags.py | 11 +- backend/app/sysconfig.py | 27 ++++- backend/tests/test_feed_cursor.py | 2 +- backend/tests/test_http_details.py | 53 +++++++++ backend/tests/test_plex_collections_filter.py | 6 +- backend/tests/test_search_scrape_ingest.py | 2 +- frontend/eslint.config.js | 21 ++++ frontend/src/i18n/backendIds.contract.test.ts | 35 ++++++ frontend/src/i18n/locales/en/errors.json | 109 +++++++++++++++++- frontend/src/i18n/locales/hu/errors.json | 109 +++++++++++++++++- .../src/lib/actionErrors.contract.test.ts | 109 ------------------ frontend/src/lib/actionErrors.ts | 10 +- frontend/src/lib/api/core.test.ts | 28 +++++ frontend/src/lib/api/core.ts | 20 +++- .../src/lib/jsxCommentText.contract.test.ts | 30 +++++ 39 files changed, 716 insertions(+), 378 deletions(-) create mode 100644 backend/app/refusal.py create mode 100644 backend/tests/test_http_details.py delete mode 100644 frontend/src/lib/actionErrors.contract.test.ts create mode 100644 frontend/src/lib/jsxCommentText.contract.test.ts diff --git a/backend/app/auth.py b/backend/app/auth.py index b8044a9c..763954cf 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -14,6 +14,7 @@ from app.db import get_db from app.models import Invite, LogEvent, User from app.security import decrypt_optional from app.utils import canonical_email +from app.refusal import refused PASSWORD_MIN_LEN = 10 @@ -341,19 +342,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User: if not user_id: - raise HTTPException(status_code=401, detail="Not authenticated") + raise refused(401, "not_authenticated") user = db.get(User, user_id) if user is None or not user.is_active or user.is_suspended: # Suspended/deactivated mid-session → end it so the block takes effect at once. _end_account_session(request, user_id, is_default) - raise HTTPException(status_code=401, detail="Not authenticated") + raise refused(401, "not_authenticated") # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. epochs = request.session.get("epochs") or {} if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): _end_account_session(request, user_id, is_default) - raise HTTPException(status_code=401, detail="Not authenticated") + raise refused(401, "not_authenticated") # Always keep the session default in the switchable list — covers sessions created before # multi-session existed (their account_ids was never seeded), so the switcher isn't empty. default_id = request.session.get("user_id") @@ -418,10 +419,7 @@ def require_write_scope(user: User = Depends(require_human)) -> User: checks that had drifted into three different 403 messages across channels/playlists. Layers on `require_human` because writing to YouTube needs a real, non-demo identity.""" if not has_write_scope(user): - raise HTTPException( - status_code=403, - detail="Enable YouTube editing in Settings first.", - ) + raise refused(403, "youtube_editing_off") return user @@ -429,7 +427,7 @@ def admin_user(user: User = Depends(current_user)) -> User: """Dependency: require the signed-in user to be an admin. Single source of truth for the admin gate — every admin route/action depends on this rather than re-checking the role.""" if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin only") + raise refused(403, "admin_only") return user diff --git a/backend/app/refusal.py b/backend/app/refusal.py new file mode 100644 index 00000000..fc70f7b6 --- /dev/null +++ b/backend/app/refusal.py @@ -0,0 +1,37 @@ +"""A request the app refuses, said in a way the UI can put in the reader's language. + +Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client +translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI +verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed +whatever sentence the route happened to hand-write. `refused` is the generic family +(`errors.refused.`, the params interpolated into the sentence); the older specific codes +(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated. + +A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail, +and the frontend's contract test requires every `refused(…, "")` to be named in both +languages — so the rule is enforced, not described. +""" + +from fastapi import HTTPException + + +def refused(status: int, reason: str, **params: object) -> HTTPException: + """The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in + the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.""" + return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params}) + + +# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's +# contract test can read every reason this can produce. +_SETTING_REASONS = { + "not_a_number": "setting_not_a_number", + "too_low": "setting_too_low", + "too_high": "setting_too_high", + "not_a_choice": "setting_not_a_choice", + "no_encryption_key": "no_encryption_key", +} + + +def setting_refused(exc) -> HTTPException: + """A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`).""" + return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params) diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index 9154ae4c..6689f6fa 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -46,18 +46,20 @@ def owned_or_404( ) -> M: """Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger. - Generalizes the near-identical per-route ownership helpers (e.g. plex's - `_own_playlist_or_404`, the saved-views owner check).""" + It replaced the near-identical per-route ownership helpers, and is called directly — a thin + `_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out.""" obj = get_or_404(db, model, id_, what=what) if getattr(obj, owner_attr) != user.id: raise not_found(what) return obj -def youtube_refusal(exc: YouTubeError) -> HTTPException: +def youtube_refusal(exc: Exception) -> HTTPException: """The answer to an action YouTube refused: a 422 whose reason the UI translates (`errors.youtube.`). One definition, because the routes had grown three shapes of it — a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at all (a raw 500 from the manual syncs). The upstream failure is already logged where it - happened (`youtube.api.error`).""" - return HTTPException(status_code=422, detail={"code": "youtube", "reason": refusal_reason(exc)}) + happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is + YouTube being unavailable to us — the retryable answer.""" + reason = refusal_reason(exc) if isinstance(exc, YouTubeError) else "unavailable" + return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason}) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 63e070da..c77f40b3 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -39,6 +39,7 @@ from app.routes.auth import ( send_verification_link, ) from app.utils import canonical_email, iso, valid_email +from app.refusal import refused router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and # mailing a rejection to an already-active account. if inv.status != "pending": - raise HTTPException(status_code=409, detail="Invite already decided") + raise refused(409, "invite_decided") inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email @@ -242,7 +243,7 @@ def add_invite( """Manually whitelist an email (approved straight away). Convenience for the admin.""" email = body.email.strip().lower() if not valid_email(email): - raise HTTPException(status_code=400, detail="Enter a valid email address.") + raise refused(400, "invalid_email") # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), # which is how an admin ends up approving the same human twice and still seeing them bounce. @@ -366,10 +367,7 @@ def delete_invite( promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" inv = get_or_404(db, Invite, invite_id, what="invite") if _accounts_for(db, [inv.email]): - raise HTTPException( - status_code=409, - detail="That address already has an account — delete the account instead.", - ) + raise refused(409, "address_has_account") still_allowed_by_env = _env_allowed(inv.email) audit.record( db, @@ -435,19 +433,19 @@ def set_user_role( account, and can't remove the last remaining admin. Emails the user when it actually changes.""" role = body.role if role not in ("user", "admin"): - raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") + raise refused(400, "invalid_role") target = get_or_404(db, User, user_id, what="user") if target.is_demo: - raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") + raise refused(400, "demo_role") if target.id == admin.id: - raise HTTPException(status_code=400, detail="You can't change your own role.") + raise refused(400, "own_role") if ( target.role == "admin" and role == "user" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): - raise HTTPException(status_code=400, detail="Can't remove the last admin.") + raise refused(400, "last_admin") changed = target.role != role if changed: audit.record( @@ -493,16 +491,16 @@ def set_user_suspended( suspended = body.suspended target = get_or_404(db, User, user_id, what="user") if target.is_demo: - raise HTTPException(status_code=400, detail="The demo account can't be suspended.") + raise refused(400, "demo_suspend") if target.id == admin.id: - raise HTTPException(status_code=400, detail="You can't suspend your own account.") + raise refused(400, "own_suspend") if ( suspended and target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): - raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") + raise refused(400, "last_active_admin") was_suspended = target.is_suspended if was_suspended != suspended: audit.record( @@ -537,17 +535,15 @@ def admin_delete_user( not the demo account, not yourself (use Settings → Account), and not the last admin.""" target = get_or_404(db, User, user_id, what="user") if target.is_demo: - raise HTTPException(status_code=400, detail="The demo account can't be deleted.") + raise refused(400, "demo_delete") if target.id == admin.id: - raise HTTPException( - status_code=400, detail="Delete your own account from Settings → Account." - ) + raise refused(400, "own_delete") if ( target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): - raise HTTPException(status_code=400, detail="Can't delete the last admin.") + raise refused(400, "last_admin") # Record BEFORE the purge — the target row (and its email) is gone afterwards. audit.record( db, @@ -691,7 +687,7 @@ def activate_user( `_activate_user_by_email`). What was missing was any way to know they had to.""" target = get_or_404(db, User, user_id, what="user") if target.is_demo: - raise HTTPException(status_code=400, detail="The demo account can't be activated.") + raise refused(400, "demo_activate") if target.is_active: return _serialize_user(target) # idempotent: a double click is not an error if not is_allowed(db, target.email): @@ -788,7 +784,7 @@ def add_demo_whitelist( sign-in). Idempotent — re-adding an existing email just returns it.""" email = body.email.strip().lower() if not valid_email(email): - raise HTTPException(status_code=400, detail="Enter a valid email address.") + raise refused(400, "invalid_email") row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none() if row is None: row = DemoWhitelist( diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 6d135751..0b614648 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -43,6 +43,7 @@ from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter, client_key from app.security import encrypt, hash_password, hash_token, verify_password from app.utils import canonical_email, valid_email +from app.refusal import refused VERIFY_TTL = timedelta(hours=24) # A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is @@ -378,7 +379,7 @@ async def callback( userinfo = token.get("userinfo") if not userinfo or not userinfo.get("sub"): - raise HTTPException(status_code=400, detail="No user info returned by Google") + raise refused(400, "google_no_userinfo") sub = userinfo["sub"] email = (userinfo.get("email") or "").lower() @@ -653,7 +654,7 @@ async def request_access( endpoint can't be probed for throttling either.""" email = (body.email or "").strip().lower() if not valid_email(email): - raise HTTPException(status_code=400, detail="Enter a valid email address.") + raise refused(400, "invalid_email") if not _request_access_limiter.allow(client_key(request)): return {"status": "pending"} if is_allowed(db, email): @@ -815,14 +816,11 @@ def register( password = body.password or "" # Input validation is safe to reveal (independent of whether the email exists). if not valid_email(email): - raise HTTPException(status_code=400, detail="Enter a valid email address.") + raise refused(400, "invalid_email") if len(password) < PASSWORD_MIN_LEN: - raise HTTPException( - status_code=400, - detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", - ) + raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) if not sysconfig.get_bool(db, "allow_registration"): - raise HTTPException(status_code=403, detail="Registration is currently closed.") + raise refused(403, "registration_closed") if not _register_limiter.allow(client_key(request)): return {"status": "ok"} # silently throttle; uniform response @@ -1293,13 +1291,10 @@ def set_password( raise HTTPException(status_code=403, detail={"code": "demo"}) new_password = body.password or "" if len(new_password) < PASSWORD_MIN_LEN: - raise HTTPException( - status_code=400, - detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.", - ) + raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) if user.password_hash: if not verify_password(body.current_password or "", user.password_hash): - raise HTTPException(status_code=403, detail="Current password is incorrect.") + raise refused(403, "wrong_password") user.password_hash = hash_password(new_password) user.password_revoked_at = None # same as the reset path: a password exists again # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 10495618..87e1623d 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -4,7 +4,7 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w from datetime import datetime, timezone -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.orm import Session @@ -31,6 +31,7 @@ from app.sync.runner import backfill_channel_now, run_recent_backfill from app.sync.subscriptions import apply_channel_details from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError +from app.refusal import refused router = APIRouter(prefix="/api/channels", tags=["channels"]) @@ -383,12 +384,9 @@ def explore_channel( backfill quota reserve; blocked for the demo account (it spends shared quota).""" channel = get_or_404(db, Channel, channel_id, what="channel") if _is_blocked(db, user, channel_id): - raise HTTPException(status_code=403, detail="You've blocked this channel.") + raise refused(403, "channel_blocked") if quota.backfill_reserve_reached(): - raise HTTPException( - status_code=429, - detail="The shared daily quota reserve is reached — try exploring later.", - ) + raise refused(429, "quota_reserve") # Record/refresh the exploration (DB only) before ingesting, so the explorer can see the # videos as soon as they land. Flag the channel ephemeral unless it's organically followed. @@ -562,7 +560,7 @@ def subscribe( ) ).scalar_one_or_none() if existing is not None: - raise HTTPException(status_code=409, detail="Already subscribed to this channel.") + raise refused(409, "already_subscribed") with ( quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), YouTubeClient(db, user) as yt, @@ -634,10 +632,7 @@ def unsubscribe( should Hide instead.""" sub = _user_subscription(db, user, channel_id) if not sub.yt_subscription_id: - raise HTTPException( - status_code=400, - detail="No YouTube subscription id on record — sync subscriptions first.", - ) + raise refused(400, "no_subscription_id") try: with ( quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index b850e6be..b5f68bf7 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -4,7 +4,7 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur import re from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy.orm import Session @@ -17,6 +17,9 @@ from app.audit import AuditAction from app.auth import admin_user from app.db import get_db from app.models import User +from app.refusal import refused +from app.routes._common import not_found +from app.refusal import setting_refused router = APIRouter(prefix="/api/admin/config", tags=["admin"]) @@ -59,19 +62,16 @@ def set_config( ) -> dict: s = sysconfig.spec(key) if s is None: - raise HTTPException(status_code=404, detail="Unknown config key") + raise not_found("config_key") if "value" not in body.model_fields_set: - raise HTTPException(status_code=400, detail="Missing 'value'") + raise refused(400, "value_missing") if s.secret and not sysconfig.secrets_manageable(): - raise HTTPException( - status_code=400, - detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", - ) + raise refused(400, "no_encryption_key") old = None if s.secret else sysconfig.get(db, key) try: sysconfig.set_value(db, key, body.value) - except (TypeError, ValueError) as exc: - raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") + except sysconfig.InvalidSetting as exc: + raise setting_refused(exc) from None # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged # but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when # a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets @@ -101,7 +101,7 @@ def reset_config( ) -> dict: s = sysconfig.spec(key) if s is None: - raise HTTPException(status_code=404, detail="Unknown config key") + raise not_found("config_key") old = None if s.secret else sysconfig.get(db, key) sysconfig.reset(db, key) audit.record( @@ -126,8 +126,8 @@ def send_test_email( """Send a test email to the requesting admin using the current (DB or env) SMTP config — lets the admin verify the settings actually work.""" if not email_mod.email_enabled(): - raise HTTPException(status_code=400, detail="SMTP is not configured.") + raise refused(400, "smtp_off") sent = email_mod.send_test(user.email, mailtext.lang_for_user(user)) if not sent: - raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") + raise refused(422, "smtp_failed") return {"sent": True, "to": user.email} diff --git a/backend/app/routes/discover.py b/backend/app/routes/discover.py index e7447c09..1ac1bea1 100644 --- a/backend/app/routes/discover.py +++ b/backend/app/routes/discover.py @@ -11,7 +11,7 @@ thing it exists to remove. from typing import Literal -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError @@ -23,7 +23,7 @@ from app.discover import hero as hero_mod from app.discover import pins, rails from app.discover.budget import Budget, DEFAULT_BUDGET from app.models import ComfortPin, User -from app.routes._common import owned_or_404 +from app.routes._common import owned_or_404, not_found router = APIRouter(prefix="/api/discover", tags=["discover"]) @@ -125,7 +125,7 @@ def add_pin( constraint is the mechanism, not the message.""" resolved = pins.resolve_ref(db, user, body.kind, body.ref) if resolved is None: - raise HTTPException(status_code=404, detail="Unknown item") + raise not_found("item") column, value = resolved existing = db.execute( @@ -157,7 +157,7 @@ def add_pin( # between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed # item), which trips the FK. Semantically that is the same "no such thing" the pre-check # returns above — re-raising handed the user a 500 they could do nothing with. - raise HTTPException(status_code=404, detail="Unknown item") + raise not_found("item") return {"ok": True, "id": winner.id, "created": False} return {"ok": True, "id": pin.id, "created": True} diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index ed479700..20dc24ff 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -42,6 +42,7 @@ from app.routes._common import get_or_404, not_found, owned_or_404 from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses from app.utils import iso +from app.refusal import refused router = APIRouter(prefix="/api/downloads", tags=["downloads"]) admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]) @@ -63,7 +64,7 @@ def resolve_source(raw: str) -> tuple[str, str]: as a raw URL for yt-dlp's generic extractor.""" raw = (raw or "").strip() if not raw: - raise HTTPException(status_code=400, detail="A video link or id is required.") + raise refused(400, "link_required") if _VIDEO_ID.match(raw): return "youtube", raw if raw.startswith(("http://", "https://")): @@ -93,7 +94,7 @@ def resolve_source(raw: str) -> tuple[str, str]: if _VIDEO_ID.match(vid): return "youtube", vid return "url", raw - raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") + raise refused(400, "invalid_link") def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: @@ -233,7 +234,7 @@ def create_profile( name = body.name.strip() spec = body.spec if not name: - raise HTTPException(status_code=400, detail="A profile name is required.") + raise refused(400, "profile_name_required") maxpos = db.execute( select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where( DownloadProfile.user_id == user.id @@ -261,16 +262,16 @@ def update_profile( ) -> dict: p = db.get(DownloadProfile, profile_id) if p is None or p.is_builtin or p.user_id != user.id: - raise HTTPException(status_code=404, detail="Unknown profile") + raise not_found("profile") sent = body.model_fields_set if "name" in sent: name = (body.name or "").strip() if not name: - raise HTTPException(status_code=400, detail="A profile name is required.") + raise refused(400, "profile_name_required") p.name = name[:80] if "spec" in sent: if body.spec is None: - raise HTTPException(status_code=400, detail="spec must be an object") + raise refused(400, "spec_not_object") p.spec = body.spec db.commit() return _serialize_profile(p) @@ -282,7 +283,7 @@ def delete_profile( ) -> dict: p = db.get(DownloadProfile, profile_id) if p is None or p.is_builtin or p.user_id != user.id: - raise HTTPException(status_code=404, detail="Unknown profile") + raise not_found("profile") db.delete(p) db.commit() return {"deleted": profile_id} @@ -312,7 +313,7 @@ def set_credential( """Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is never echoed back, logged, or audited — only the fact that it changed.""" if not dlcreds.is_supported_host(host_key): - raise HTTPException(status_code=404, detail="Unknown host") + raise not_found("host") try: cleaned = dlcreds.validate_cookies(body.cookies) except dlcreds.InvalidCookies as e: @@ -334,7 +335,7 @@ def delete_credential( host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: if not dlcreds.is_supported_host(host_key): - raise HTTPException(status_code=404, detail="Unknown host") + raise not_found("host") if dlcreds.delete_credential(db, user.id, host_key): audit.record( db, @@ -355,7 +356,7 @@ def _resolve_spec( if profile_id is not None: p = db.get(DownloadProfile, profile_id) if p is None or (not p.is_builtin and p.user_id != user.id): - raise HTTPException(status_code=404, detail="Unknown profile") + raise not_found("profile") return p.spec, p.id if spec is not None: return spec, None @@ -366,7 +367,7 @@ def _resolve_spec( .limit(1) ).scalar_one_or_none() if fallback is None: - raise HTTPException(status_code=400, detail="No download profile available.") + raise refused(400, "no_profile") return fallback.spec, fallback.id @@ -477,7 +478,7 @@ def enqueue_edit( source file is only read, never modified, so editing a shared video is safe.""" source_job = _accessible_job(db, user, body.source_job_id) if source_job.status != "done" or source_job.asset_id is None: - raise HTTPException(status_code=409, detail="You can only edit a finished download.") + raise refused(409, "edit_unfinished") spec = body.edit_spec display_name = (body.display_name or "").strip() or None try: @@ -661,9 +662,9 @@ def redownload_download( if job.job_kind in ("edit", "upload"): # Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the # source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail. - raise HTTPException(status_code=409, detail="This item can't be re-downloaded.") + raise refused(409, "not_redownloadable") if job.status in ("queued", "running", "paused"): - raise HTTPException(status_code=409, detail="This download is still in progress.") + raise refused(409, "still_downloading") service.redownload(db, job) return _serialize_job(db, job) @@ -715,10 +716,10 @@ def download_file( job = _accessible_job(db, user, job_id) asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None if asset is None or asset.status != "ready" or not asset.rel_path: - raise HTTPException(status_code=409, detail="This download isn't ready.") + raise refused(409, "download_not_ready") path = storage.safe_abs_path(settings.download_root, asset.rel_path) if path is None: - raise HTTPException(status_code=404, detail="File is no longer available.") + raise not_found("file") filename = storage.download_filename( job.display_name or asset.title or asset.source_ref, asset.container, path @@ -757,7 +758,7 @@ def storyboard_meta( job = _accessible_job(db, user, job_id) asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None if asset is None or asset.status != "ready" or not asset.rel_path: - raise HTTPException(status_code=409, detail="This download isn't ready.") + raise refused(409, "download_not_ready") meta = editmod.ensure_storyboard(asset, settings.download_root) if meta is None: return {"available": False} @@ -778,10 +779,10 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session = job = _accessible_job(db, user, job_id) asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None if asset is None or not asset.poster_path: - raise HTTPException(status_code=404, detail="No poster.") + raise not_found("poster") path = storage.safe_abs_path(settings.download_root, asset.poster_path) if path is None: - raise HTTPException(status_code=404, detail="No poster.") + raise not_found("poster") return FileResponse(path, media_type="image/jpeg") @@ -792,10 +793,10 @@ def storyboard_image( job = _accessible_job(db, user, job_id) asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None if asset is None or not asset.storyboard_path: - raise HTTPException(status_code=404, detail="No filmstrip.") + raise not_found("filmstrip") path = storage.safe_abs_path(settings.download_root, asset.storyboard_path) if path is None: - raise HTTPException(status_code=404, detail="No filmstrip.") + raise not_found("filmstrip") return FileResponse(path, media_type="image/jpeg") @@ -815,10 +816,10 @@ def share_download( ) -> dict: job = owned_or_404(db, DownloadJob, job_id, user, what="download") if job.status != "done" or job.asset_id is None: - raise HTTPException(status_code=409, detail="Only a finished download can be shared.") + raise refused(409, "share_unfinished") target = body.email.strip().lower() if not target: - raise HTTPException(status_code=400, detail="A recipient email is required.") + raise refused(400, "recipient_required") recipient = db.execute( select(User).where(func.lower(User.email) == target) ).scalar_one_or_none() @@ -827,7 +828,7 @@ def share_download( if not is_messageable_user(recipient): raise not_found("recipient") if recipient.id == user.id: - raise HTTPException(status_code=400, detail="That's already your download.") + raise refused(400, "share_own") exists = db.execute( select(DownloadShare.id).where( DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id @@ -972,7 +973,7 @@ def create_link( ) -> dict: job = owned_or_404(db, DownloadJob, job_id, user, what="download") if job.status != "done" or job.asset_id is None: - raise HTTPException(status_code=409, detail="Only a finished download can be shared.") + raise refused(409, "share_unfinished") pw = body.password.strip() link = DownloadLink( job_id=job_id, diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index b38bbdb1..940199c8 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,6 +1,6 @@ from datetime import date, datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import and_, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -29,10 +29,11 @@ from app.models import ( Video, VideoState, ) -from app.routes._common import get_or_404, youtube_refusal +from app.routes._common import get_or_404, youtube_refusal, not_found from app.sync.videos import parse_iso8601_duration from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError +from app.refusal import refused router = APIRouter(prefix="/api", tags=["feed"]) @@ -120,7 +121,7 @@ def get_feed( vals, cursor_id = decode_cursor(keys, cursor) except ValueError: # `from None`: the ValueError is an expected bad-input signal, not an error to chain. - raise HTTPException(status_code=400, detail="Invalid feed cursor") from None + raise refused(400, "invalid_cursor") from None for k, v in zip(keys, vals): k["value"] = v query = query.where(keyset_predicate(keys, cursor_id)) @@ -235,7 +236,7 @@ def set_video_state( ) -> dict: status = body.status if status not in VALID_STATES: - raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") + raise refused(400, "invalid_status") get_or_404(db, Video, video_id, what="video") now = datetime.now(timezone.utc) @@ -402,7 +403,7 @@ def get_video_detail( # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the # demo account can't reach it — it just sees "unknown" for a video we don't already store. if user.is_demo: - raise HTTPException(status_code=404, detail="Unknown video") + raise not_found("video") try: with ( @@ -413,7 +414,7 @@ def get_video_detail( except YouTubeError as exc: raise youtube_refusal(exc) if not items: - raise HTTPException(status_code=404, detail="Unknown video") + raise not_found("video") snippet = items[0].get("snippet", {}) stats = items[0].get("statistics", {}) diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 74b15360..ce6fab67 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -24,6 +24,8 @@ from app import sysconfig from app.db import get_db from app.models import Invite, User from app.utils import iso +from app.refusal import refused +from app.routes._common import not_found router = APIRouter(prefix="/api/me", tags=["me"]) @@ -70,7 +72,7 @@ def switch_account( here) are allowed, and access is re-checked in case the invite was revoked since.""" target = body.user_id if target not in (request.session.get("account_ids") or []): - raise HTTPException(status_code=403, detail="Not an account you've signed into here.") + raise refused(403, "not_your_account") # An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is # refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session # default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole @@ -78,22 +80,20 @@ def switch_account( u = db.get(User, target) if u is None: drop_from_wallet(request.session, target) - raise HTTPException(status_code=404, detail="That account no longer exists.") + raise not_found("account") if not is_allowed(db, u.email): - raise HTTPException(status_code=403, detail="That account no longer has access.") + raise refused(403, "account_no_access") if not u.is_active: drop_from_wallet(request.session, target) - raise HTTPException(status_code=403, detail="That account no longer has access.") + raise refused(403, "account_no_access") if u.is_suspended: drop_from_wallet(request.session, target) - raise HTTPException(status_code=403, detail="That account is suspended.") + raise refused(403, "account_suspended") # Revoked: a password reset/change or "log out everywhere" bumped the account's epoch. epochs = request.session.get("epochs") or {} if int(epochs.get(str(target), 0)) != (u.session_epoch or 0): drop_from_wallet(request.session, target) - raise HTTPException( - status_code=403, detail="That account was signed out elsewhere — sign in again." - ) + raise refused(403, "account_signed_out") request.session["user_id"] = target return {"ok": True, "user_id": target} @@ -181,10 +181,7 @@ def delete_my_account( if user.is_demo: raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"}) if user.role == "admin" and count_admins(db) <= 1: - raise HTTPException( - status_code=400, - detail="You're the only admin — promote another admin before deleting your account.", - ) + raise refused(400, "only_admin_delete") user_id = user.id # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index e9f705c6..64156870 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -16,7 +16,7 @@ routing and unread counts. from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from pydantic import BaseModel from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy.orm import Session @@ -29,6 +29,8 @@ from app.realtime import manager from app.security import verify_password from app.userscope import is_messageable_user, messageable_clauses from app.utils import iso +from app.refusal import refused +from app.routes._common import not_found router = APIRouter(prefix="/api/messages", tags=["messages"]) @@ -152,7 +154,7 @@ def setup_keys( """Store this user's public key + passphrase-wrapped private key. Set-once: changing the key would orphan every existing conversation key, so re-setup is rejected here.""" if db.get(MessageKey, user.id) is not None: - raise HTTPException(status_code=409, detail="Secure messaging is already set up.") + raise refused(409, "messaging_set_up") db.add( MessageKey( user_id=user.id, @@ -185,11 +187,11 @@ def reset_keys( less (Google-only) account has nothing to re-verify, so it relies on its live session plus the client-side danger confirmation.""" if not _reset_key_limiter.allow(str(user.id)): - raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") + raise refused(429, "too_many_attempts") if user.password_hash and not verify_password( payload.current_password or "", user.password_hash ): - raise HTTPException(status_code=403, detail="Current password is incorrect.") + raise refused(403, "wrong_password") # Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a # fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit. db.execute( @@ -223,10 +225,10 @@ def public_key( p = db.get(User, user_id) has_history = db.scalar(select(Message.id).where(cond).limit(1)) if p is None or (not is_messageable_user(p) and not has_history): - raise HTTPException(status_code=404, detail="User not found") + raise not_found("user") k = db.get(MessageKey, user_id) if k is None: - raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") + raise refused(404, "no_messaging") return {"user_id": user_id, "public_key": k.public_key} @@ -344,7 +346,7 @@ def get_thread( # still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle. has_history = db.scalar(select(Message.id).where(cond).limit(1)) if p is None or (not is_messageable_user(p) and not has_history): - raise HTTPException(status_code=404, detail="User not found") + raise not_found("user") partner = _serialize_user(p) msgs = ( db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all() @@ -374,20 +376,20 @@ def send_message( """Send an end-to-end-encrypted message. The server stores only the ciphertext the client produced, then pushes it live to the recipient (and the sender's other devices).""" if not _send_limiter.allow(f"msg:{user.id}"): - raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.") + raise refused(429, "message_rate") ct = (payload.ciphertext or "").strip() iv = (payload.iv or "").strip() if not ct or not iv: - raise HTTPException(status_code=400, detail="Message is empty.") + raise refused(400, "message_empty") if len(ct) > MAX_CIPHERTEXT: - raise HTTPException(status_code=400, detail="Message is too long.") + raise refused(400, "message_too_long") if payload.recipient_id == user.id: - raise HTTPException(status_code=400, detail="You can't message yourself.") + raise refused(400, "message_self") recipient = db.get(User, payload.recipient_id) if not is_messageable_user(recipient): - raise HTTPException(status_code=404, detail="Recipient not available.") + raise not_found("recipient") if db.get(MessageKey, recipient.id) is None: - raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") + raise refused(404, "no_messaging") m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv) db.add(m) db.commit() diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index 02ec2fc3..78319f9c 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -1,11 +1,11 @@ """Shared helpers, serializers and constants for the plex route package.""" -from fastapi import HTTPException from sqlalchemy.orm import Session from app import logs from app.models import PlexItem from app.plex.lookups import by_rating_key +from app.routes._common import not_found log = logs.get_logger(__name__) @@ -13,7 +13,7 @@ log = logs.get_logger(__name__) def item_or_404(db: Session, rating_key: str) -> PlexItem: it = by_rating_key(db, PlexItem, rating_key) if it is None: - raise HTTPException(status_code=404, detail="Unknown Plex item") + raise not_found("plex_item") return it diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py index 23600b0b..e9a2af74 100644 --- a/backend/app/routes/plex/admin.py +++ b/backend/app/routes/plex/admin.py @@ -2,12 +2,12 @@ from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session -from app import sysconfig +from app import logs, sysconfig from app.auth import admin_user, current_user from app.db import get_db from app.models import ( @@ -20,6 +20,9 @@ from app.plex import sync as plex_sync from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.utils import iso +from app.refusal import refused + +log = logs.get_logger(__name__) router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -35,10 +38,15 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db) info = plex.server_info() sections = plex.sections() media_check = _media_check(db, plex, sections) - except PlexNotConfigured as e: - raise HTTPException(status_code=400, detail=str(e)) + except PlexNotConfigured: + raise refused(400, "plex_not_configured") from None except PlexError as e: - raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}") + # What the upstream said is for the log, not the toast: it can quote the server URL back. + log.warning( + "the Plex connection test failed", + extra=logs.event("plex.admin.connection_failed", reason=type(e).__name__, error=str(e)), + ) + raise refused(422, "plex_unreachable") from None return { "ok": True, "server_name": info.get("friendlyName") or info.get("title1") or "Plex", @@ -129,7 +137,7 @@ def watch_link_set( admin token, so no separate Plex login. Enabling it for the first time runs the one-time "Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode.""" if not sysconfig.get_bool(db, "plex_enabled"): - raise HTTPException(status_code=409, detail="The Plex module is disabled.") + raise refused(409, "plex_disabled") enabled = body.enabled link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() if link is None: @@ -150,6 +158,6 @@ def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get intersection; Siftlode-only states are preserved. Idempotent.""" link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first() if link is None or not link.sync_enabled: - raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.") + raise refused(409, "watch_sync_off") result = plex_watch.import_owner_watch_state(db, link) return {**_link_dict(link), "import": result} diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index ada9eb92..f22c9288 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session +from app import logs from app.auth import admin_user, current_user from app.db import get_db from app.models import ( @@ -26,6 +27,10 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key from app.plex.query import content_libs, runtime_by_key, top_art_keys from app.utils import like_escape +from app.refusal import refused +from app.routes._common import not_found + +log = logs.get_logger(__name__) # The most-restrictive → least ordering used to pick a collection's effective content rating from its # members (Plex shows one cert on the collection; we don't store it, so derive it). @@ -234,7 +239,7 @@ def collection_detail( endpoints below.""" col = by_rating_key(db, PlexCollection, rating_key) if col is None: - raise HTTPException(status_code=404, detail="Unknown collection") + raise not_found("collection") movies = ( db.execute( select(PlexItem) @@ -325,9 +330,9 @@ def collection_detail( def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: col = by_rating_key(db, PlexCollection, rating_key) if col is None: - raise HTTPException(status_code=404, detail="Unknown collection") + raise not_found("collection") if not _collection_editable(col): - raise HTTPException(status_code=403, detail="This collection is read-only") + raise refused(403, "collection_read_only") return col @@ -362,14 +367,13 @@ def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException: Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary action-error toast, which is what both of these are — the app answered, it didn't go unreachable.""" - if wrote: - detail = ( - "Collection updated in Plex, but refreshing the local copy failed; " - f"it will reconcile on the next sync ({e})" - ) - else: - detail = f"Plex write failed: {e}" - return HTTPException(status_code=422, detail=detail) + log.warning( + "a Plex collection write did not settle", + extra=logs.event( + "plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e) + ), + ) + return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed") class CreateCollectionIn(BaseModel): @@ -389,7 +393,7 @@ def create_collection( title = body.title.strip() seed = body.item_rating_key if not title or not seed: - raise HTTPException(status_code=400, detail="title and item_rating_key are required") + raise refused(400, "collection_fields_required") if library: lib = ( db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() @@ -399,7 +403,7 @@ def create_collection( seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed) lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None if lib is None: - raise HTTPException(status_code=404, detail="Unknown library") + raise not_found("library") _reject_duplicate_name(db, lib.id, title) wrote = False try: @@ -473,7 +477,7 @@ def collection_rename( col = _editable_collection_or_403(db, rating_key) title = body.title.strip() if not title: - raise HTTPException(status_code=400, detail="title is required") + raise refused(400, "title_required") _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) lib = db.get(PlexLibrary, col.library_id) wrote = False @@ -502,7 +506,7 @@ def collection_delete( db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() - raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") + raise _write_or_pending_error(False, e) from None return {"deleted": rating_key} @@ -521,13 +525,10 @@ def collection_set_editable( (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" col = by_rating_key(db, PlexCollection, rating_key) if col is None: - raise HTTPException(status_code=404, detail="Unknown collection") + raise not_found("collection") want = body.editable if want and (col.smart or _collection_source(col) != "collection"): - raise HTTPException( - status_code=400, - detail="Smart / auto-list collections can't be made editable", - ) + raise refused(400, "collection_smart") col.editable = want db.commit() return _collection_card(col) diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 8a52df0e..f621ba6b 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -5,7 +5,7 @@ import time from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -30,6 +30,7 @@ from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key from app.routes.plex._shared import _norm_lang, item_or_404 from app.routes.plex.collections import _collection_source +from app.routes._common import not_found log = logs.get_logger(__name__) @@ -109,7 +110,7 @@ def show_detail( hu = _norm_lang(lang) == "hu" 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") seasons = ( db.execute( select(PlexSeason) diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index fbda4a1d..4114b6e5 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -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 ; 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], diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py index 9728b1d3..9cd61fd5 100644 --- a/backend/app/routes/plex/playlists.py +++ b/backend/app/routes/plex/playlists.py @@ -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( diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index e580a77c..7dafd1fb 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -6,7 +6,7 @@ the app or any account data. Password-protected links exchange the password once a short-lived signed grant that the media URL carries (a `