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 -8
View File
@@ -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
+37
View File
@@ -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.<reason>`, 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(…, "<reason>")` 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)
+7 -5
View File
@@ -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.<reason>`). 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})
+16 -20
View File
@@ -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(
+8 -13
View File
@@ -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
+6 -11
View File
@@ -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),
+12 -12
View File
@@ -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}
+4 -4
View File
@@ -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}
+26 -25
View File
@@ -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,
+7 -6
View File
@@ -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", {})
+9 -12
View File
@@ -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.
+15 -13
View File
@@ -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()
+2 -2
View File
@@ -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
+15 -7
View File
@@ -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}
+21 -20
View File
@@ -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)
+3 -2
View File
@@ -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)
+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],
+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(
+13 -11
View File
@@ -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 `<video src>` can't send a header).
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
@@ -19,6 +19,8 @@ from app.downloads import storage
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/public", tags=["public"])
@@ -37,7 +39,7 @@ _unlock_failures = RateLimiter(max_events=50, window_seconds=300)
def _link_or_404(db: Session, token: str) -> DownloadLink:
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
if link is None or linksmod.is_expired(link):
raise HTTPException(status_code=404, detail="This link is no longer available.")
raise refused(404, "link_gone")
return link
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=404, detail="This video is no longer available.")
raise refused(404, "video_gone")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree.
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None:
raise HTTPException(status_code=404, detail="This video is no longer available.")
raise refused(404, "video_gone")
return job, asset
@@ -100,7 +102,7 @@ def watch_unlock(
link = _link_or_404(db, token)
viewer = f"{token}|{client_key(request)}"
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
raise refused(429, "too_many_attempts")
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link)
@@ -110,7 +112,7 @@ def watch_unlock(
}
if not verify_password(body.password, link.password_hash):
_unlock_failures.allow(token)
raise HTTPException(status_code=403, detail="Wrong password.")
raise refused(403, "wrong_share_password")
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
@@ -129,11 +131,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
`allow_download` link serves it as an attachment. Password links require a valid grant."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.")
raise refused(403, "share_password_required")
asset = _ready_asset(db, link)
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None:
raise HTTPException(status_code=404, detail="This video is no longer available.")
raise refused(404, "video_gone")
filename = storage.download_filename(asset.title or "video", asset.container, path)
# The served Content-Type comes from the asset's ffprobe-verified media_kind + a container
@@ -163,11 +165,11 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
and the link-preview image). Password links require a valid grant, like the file."""
link = _link_or_404(db, token)
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
raise HTTPException(status_code=403, detail="This link needs a password.")
raise refused(403, "share_password_required")
job, asset = _ready_target(db, link)
if 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")
+7 -6
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
@@ -8,6 +8,7 @@ from app.auth import require_human
from app.db import get_db
from app.models import SavedView, User
from app.routes._common import owned_or_404
from app.refusal import refused
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
@@ -66,7 +67,7 @@ def create_view(
) -> dict:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
raise refused(400, "name_required")
source = _norm_source(body.source)
# Position is per (user, source), so each module's views number 0..N independently.
maxpos = db.execute(
@@ -86,7 +87,7 @@ def create_view(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a view named “{name}”.")
raise refused(409, "view_name_taken", name=name)
return _serialize(view)
@@ -110,11 +111,11 @@ def update_view(
if "name" in sent:
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
raise refused(400, "name_required")
view.name = name[:80]
if "filters" in sent:
if body.filters is None:
raise HTTPException(status_code=400, detail="filters must be an object")
raise refused(400, "filters_not_object")
view.filters = body.filters
if "is_default" in sent:
make_default = bool(body.is_default)
@@ -134,7 +135,7 @@ def update_view(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a view named “{view.name}”.")
raise refused(409, "view_name_taken", name=view.name)
return _serialize(view)
+7 -8
View File
@@ -2,7 +2,7 @@
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -25,6 +25,8 @@ from app.scheduler import (
from app.sync.explore import followed_channel
from app.sync.runner import estimate_deep_backfill
from app.routes._common import not_found
from app.refusal import refused
from app.refusal import setting_refused
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
@@ -46,10 +48,7 @@ def set_job_interval(
raise not_found("job")
minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException(
status_code=400,
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
)
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
applied = apply_interval(db, job_id, minutes)
audit.record(
db,
@@ -77,7 +76,7 @@ def run_job_now(
except KeyError:
raise not_found("job")
if result == "already_running":
raise HTTPException(status_code=409, detail="Job is already running")
raise refused(409, "job_running")
return {"id": job_id, "started": True}
@@ -102,8 +101,8 @@ def set_maintenance_settings(
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
try:
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
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
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
audit.record(
db,
+15 -16
View File
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -44,6 +44,8 @@ from app.sync.videos import insert_stubs, apply_video_details, parse_dt
from app.youtube import search_scrape
from app.youtube.client import YouTubeClient, YouTubeError
from app.youtube.shorts import make_client, probe_is_short
from app.refusal import refused
from app.routes._common import youtube_refusal
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/search", tags=["search"])
@@ -258,7 +260,7 @@ def search_youtube(
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
term = (q or "").strip()
if not term:
raise HTTPException(status_code=400, detail="A search term is required.")
raise refused(400, "search_term_required")
limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
@@ -275,20 +277,14 @@ def search_youtube(
"a live search was refused: the per-user daily cap is spent",
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
)
raise HTTPException(
status_code=429,
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
)
raise refused(429, "search_daily_limit", limit=daily_limit)
# The 100-unit budget pre-check only applies to the API source.
if source == "api" and not quota.can_spend(db, SEARCH_COST):
log.info(
"a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
)
raise HTTPException(
status_code=429,
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
)
raise refused(429, "quota_spent")
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = set(
@@ -313,9 +309,14 @@ def search_youtube(
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
# which hides the real message and burns a second quota-costed search. Upstream failure
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
raise HTTPException(
status_code=422, detail=f"YouTube search failed: {exc}"
) from exc
# A Data API failure is logged where it happened; the scraper's is not, and its text
# no longer rides the answer — so it is said here.
if isinstance(exc, search_scrape.ScrapeError):
log.warning(
"the YouTube search scrape failed",
extra=logs.event("search.scrape_failed", error=str(exc)),
)
raise youtube_refusal(exc) from exc
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
candidates = [
@@ -357,9 +358,7 @@ def search_youtube(
)
if collected:
break # keep what earlier pages already gathered
raise HTTPException(
status_code=422, detail=f"YouTube search failed: {exc}"
) from exc
raise youtube_refusal(exc) from exc
next_cursor = page["next_page_token"]
# The API source pages cost 100 units each — never auto-page; one page per request.
if source == "api" or len(collected) >= limit or not next_cursor:
+13 -13
View File
@@ -8,7 +8,7 @@ X-Setup-Token header).
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
from app.security import hash_password
from app.refusal import refused
from app.refusal import setting_refused
router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -31,9 +33,9 @@ def require_setup(
) -> Session:
"""Gate for setup steps. Returns the db session for the handler to reuse."""
if state.is_configured(db):
raise HTTPException(status_code=409, detail="This instance is already set up.")
raise refused(409, "already_set_up")
if not state.check_setup_token(db, x_setup_token):
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
raise refused(403, "setup_token")
return db
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
email = body.email.strip().lower()
password = body.password
if "@" not in 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)
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
@@ -97,13 +97,13 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
saved: list[str] = []
for key, value in settings_map.items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
raise refused(400, "unknown_setting", key=key)
if value is None or value == "":
continue
try:
sysconfig.set_value(db, key, value)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except sysconfig.InvalidSetting as exc:
raise setting_refused(exc) from None
saved.append(key)
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
return {"saved": saved}
@@ -121,9 +121,9 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
"""Send a test email to confirm the SMTP settings the wizard just saved."""
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
raise refused(400, "invalid_email")
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
raise refused(400, "smtp_failed")
return {"ok": True}
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
invalidates the setup token and disables the wizard)."""
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
if not has_admin:
raise HTTPException(status_code=400, detail="Create the admin account first.")
raise refused(400, "admin_first")
state.mark_configured(db)
return {"ok": True}
+2 -4
View File
@@ -13,6 +13,7 @@ from app.sync.runner import estimate_deep_backfill
from app.sync.subscriptions import import_subscriptions
from app.scheduler import running_job_ids
from app.youtube.client import YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api/sync", tags=["sync"])
@@ -28,10 +29,7 @@ _sync_limiter = RateLimiter(max_events=3, window_seconds=600)
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
if not has_read_scope(user):
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
raise HTTPException(
status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.",
)
raise refused(403, "youtube_read_scope")
if not _sync_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
try:
+6 -5
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
@@ -9,6 +9,7 @@ from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404
from app.sync.tagsuggest import compute_tag_suggestions
from app.refusal import refused
router = APIRouter(prefix="/api/tags", tags=["tags"])
@@ -60,7 +61,7 @@ def create_tag(
) -> dict:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
raise refused(400, "name_required")
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
category = body.category if body.category in ("language", "topic", "other") else "other"
tag = Tag(
@@ -74,7 +75,7 @@ def create_tag(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.")
raise refused(409, "tag_name_taken", name=name)
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
@@ -98,7 +99,7 @@ def update_tag(
if "name" in sent:
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
raise refused(400, "name_required")
tag.name = name
if "color" in sent:
tag.color = body.color
@@ -106,7 +107,7 @@ def update_tag(
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.")
raise refused(409, "tag_name_taken", name=tag.name)
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
+21 -6
View File
@@ -212,11 +212,23 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr)
class InvalidSetting(ValueError):
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
caller that only needs "refused or not" keeps working."""
def __init__(self, key: str, reason: str, **params: object):
super().__init__(f"{key}: {reason} {params}")
self.key = key
self.reason = reason
self.params = params
def _choice(s: ConfigSpec, raw: object) -> str:
"""The canonical spelling of `raw` among the key's choices (any letter case), or ValueError."""
"""The canonical spelling of `raw` among the key's choices (any letter case), or InvalidSetting."""
option = canonical_choice(s.choices, raw)
if option is None:
raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}")
raise InvalidSetting(s.key, "not_a_choice", choices=", ".join(s.choices))
return option
@@ -306,11 +318,14 @@ def set_value(db: Session, key: str, value) -> None:
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
s = _BY_KEY[key]
if s.type == "int":
v = int(value)
try:
v = int(value)
except (TypeError, ValueError):
raise InvalidSetting(key, "not_a_number") from None
if s.min is not None and v < s.min:
raise ValueError(f"{key} must be >= {s.min}")
raise InvalidSetting(key, "too_low", min=s.min)
if s.max is not None and v > s.max:
raise ValueError(f"{key} must be <= {s.max}")
raise InvalidSetting(key, "too_high", max=s.max)
raw = str(v)
elif s.type == "bool":
raw = "true" if value else "false"
@@ -322,7 +337,7 @@ def set_value(db: Session, key: str, value) -> None:
stored, encrypted = raw, False
if s.secret:
if not secrets_manageable():
raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured")
raise InvalidSetting(key, "no_encryption_key")
stored, encrypted = security.encrypt(raw) or "", True
row = db.get(SystemConfig, key)
+1 -1
View File
@@ -34,7 +34,7 @@ def test_malformed_feed_cursor_returns_400(db, monkeypatch):
db.commit()
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid feed cursor"
assert resp.json()["detail"] == {"code": "refused", "reason": "invalid_cursor"}
def test_feed_without_cursor_is_200(db, monkeypatch):
+53
View File
@@ -0,0 +1,53 @@
"""No refusal answers in a hand-written English sentence.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
"""
import ast
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
def _is_text(node: ast.AST) -> bool:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return True
if isinstance(node, ast.JoinedStr):
return True
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence():
offenders = []
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
f"(app/refusal.py) and name the reason in both locales: {offenders}"
)
def test_the_scan_sees_a_sentence_when_there_is_one():
"""A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
assert _is_text(sample.keywords[1].value)
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
@@ -346,7 +346,7 @@ def test_create_resync_failure_is_422(db, monkeypatch):
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
)
assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "create_collection" in stub.calls
finally:
app.dependency_overrides.clear()
@@ -361,7 +361,7 @@ def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text
assert "write failed" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "plex_write_failed"}
finally:
app.dependency_overrides.clear()
@@ -389,7 +389,7 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text
assert "reconcile" in r.json()["detail"].lower()
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
+1 -1
View File
@@ -153,4 +153,4 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
app.dependency_overrides.clear()
assert resp.status_code == 422, resp.text
assert "YouTube search failed" in resp.json()["detail"]
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}