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:
+6
-8
@@ -14,6 +14,7 @@ from app.db import get_db
|
|||||||
from app.models import Invite, LogEvent, User
|
from app.models import Invite, LogEvent, User
|
||||||
from app.security import decrypt_optional
|
from app.security import decrypt_optional
|
||||||
from app.utils import canonical_email
|
from app.utils import canonical_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
PASSWORD_MIN_LEN = 10
|
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:
|
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None or not user.is_active or user.is_suspended:
|
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.
|
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||||
_end_account_session(request, user_id, is_default)
|
_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
|
# 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
|
# 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.
|
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
|
||||||
_end_account_session(request, user_id, is_default)
|
_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
|
# 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.
|
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
|
||||||
default_id = request.session.get("user_id")
|
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
|
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."""
|
`require_human` because writing to YouTube needs a real, non-demo identity."""
|
||||||
if not has_write_scope(user):
|
if not has_write_scope(user):
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_editing_off")
|
||||||
status_code=403,
|
|
||||||
detail="Enable YouTube editing in Settings first.",
|
|
||||||
)
|
|
||||||
return user
|
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
|
"""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."""
|
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise refused(403, "admin_only")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -46,18 +46,20 @@ def owned_or_404(
|
|||||||
) -> M:
|
) -> M:
|
||||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
"""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.
|
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
|
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||||
`_own_playlist_or_404`, the saved-views owner check)."""
|
`_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)
|
obj = get_or_404(db, model, id_, what=what)
|
||||||
if getattr(obj, owner_attr) != user.id:
|
if getattr(obj, owner_attr) != user.id:
|
||||||
raise not_found(what)
|
raise not_found(what)
|
||||||
return obj
|
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
|
"""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 —
|
(`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
|
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
|
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
|
||||||
happened (`youtube.api.error`)."""
|
happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
|
||||||
return HTTPException(status_code=422, detail={"code": "youtube", "reason": refusal_reason(exc)})
|
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
@@ -39,6 +39,7 @@ from app.routes.auth import (
|
|||||||
send_verification_link,
|
send_verification_link,
|
||||||
)
|
)
|
||||||
from app.utils import canonical_email, iso, valid_email
|
from app.utils import canonical_email, iso, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
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
|
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||||
# mailing a rejection to an already-active account.
|
# mailing a rejection to an already-active account.
|
||||||
if inv.status != "pending":
|
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.status = "approved" if approved else "denied"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
@@ -242,7 +243,7 @@ def add_invite(
|
|||||||
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
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
|
# 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@),
|
# 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.
|
# 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."""
|
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")
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||||
if _accounts_for(db, [inv.email]):
|
if _accounts_for(db, [inv.email]):
|
||||||
raise HTTPException(
|
raise refused(409, "address_has_account")
|
||||||
status_code=409,
|
|
||||||
detail="That address already has an account — delete the account instead.",
|
|
||||||
)
|
|
||||||
still_allowed_by_env = _env_allowed(inv.email)
|
still_allowed_by_env = _env_allowed(inv.email)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
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."""
|
account, and can't remove the last remaining admin. Emails the user when it actually changes."""
|
||||||
role = body.role
|
role = body.role
|
||||||
if role not in ("user", "admin"):
|
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")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
raise refused(400, "own_role")
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and role == "user"
|
and role == "user"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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
|
changed = target.role != role
|
||||||
if changed:
|
if changed:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -493,16 +491,16 @@ def set_user_suspended(
|
|||||||
suspended = body.suspended
|
suspended = body.suspended
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
raise refused(400, "own_suspend")
|
||||||
if (
|
if (
|
||||||
suspended
|
suspended
|
||||||
and target.role == "admin"
|
and target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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
|
was_suspended = target.is_suspended
|
||||||
if was_suspended != suspended:
|
if was_suspended != suspended:
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -537,17 +535,15 @@ def admin_delete_user(
|
|||||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||||
target = get_or_404(db, User, user_id, what="user")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.id == admin.id:
|
||||||
raise HTTPException(
|
raise refused(400, "own_delete")
|
||||||
status_code=400, detail="Delete your own account from Settings → Account."
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target.role == "admin"
|
target.role == "admin"
|
||||||
and not target.is_suspended
|
and not target.is_suspended
|
||||||
and count_admins(db, active_only=True) <= 1
|
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.
|
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -691,7 +687,7 @@ def activate_user(
|
|||||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
`_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")
|
target = get_or_404(db, User, user_id, what="user")
|
||||||
if target.is_demo:
|
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:
|
if target.is_active:
|
||||||
return _serialize_user(target) # idempotent: a double click is not an error
|
return _serialize_user(target) # idempotent: a double click is not an error
|
||||||
if not is_allowed(db, target.email):
|
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."""
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
if not valid_email(email):
|
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()
|
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
|
||||||
if row is None:
|
if row is None:
|
||||||
row = DemoWhitelist(
|
row = DemoWhitelist(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
|||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||||
from app.utils import canonical_email, valid_email
|
from app.utils import canonical_email, valid_email
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
VERIFY_TTL = timedelta(hours=24)
|
VERIFY_TTL = timedelta(hours=24)
|
||||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
# 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")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo or not userinfo.get("sub"):
|
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"]
|
sub = userinfo["sub"]
|
||||||
email = (userinfo.get("email") or "").lower()
|
email = (userinfo.get("email") or "").lower()
|
||||||
@@ -653,7 +654,7 @@ async def request_access(
|
|||||||
endpoint can't be probed for throttling either."""
|
endpoint can't be probed for throttling either."""
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if not valid_email(email):
|
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)):
|
if not _request_access_limiter.allow(client_key(request)):
|
||||||
return {"status": "pending"}
|
return {"status": "pending"}
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
@@ -815,14 +816,11 @@ def register(
|
|||||||
password = body.password or ""
|
password = body.password or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not valid_email(email):
|
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:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if not sysconfig.get_bool(db, "allow_registration"):
|
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)):
|
if not _register_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"status": "ok"} # silently throttle; uniform response
|
||||||
|
|
||||||
@@ -1293,13 +1291,10 @@ def set_password(
|
|||||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||||
new_password = body.password or ""
|
new_password = body.password or ""
|
||||||
if len(new_password) < PASSWORD_MIN_LEN:
|
if len(new_password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400,
|
|
||||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
|
||||||
)
|
|
||||||
if user.password_hash:
|
if user.password_hash:
|
||||||
if not verify_password(body.current_password or "", 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_hash = hash_password(new_password)
|
||||||
user.password_revoked_at = None # same as the reset path: a password exists again
|
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
|
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side w
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
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.sync.subscriptions import apply_channel_details
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
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)."""
|
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||||
if _is_blocked(db, user, channel_id):
|
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():
|
if quota.backfill_reserve_reached():
|
||||||
raise HTTPException(
|
raise refused(429, "quota_reserve")
|
||||||
status_code=429,
|
|
||||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
|
# 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.
|
# 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()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise HTTPException(status_code=409, detail="Already subscribed to this channel.")
|
raise refused(409, "already_subscribed")
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
|
||||||
YouTubeClient(db, user) as yt,
|
YouTubeClient(db, user) as yt,
|
||||||
@@ -634,10 +632,7 @@ def unsubscribe(
|
|||||||
should Hide instead."""
|
should Hide instead."""
|
||||||
sub = _user_subscription(db, user, channel_id)
|
sub = _user_subscription(db, user, channel_id)
|
||||||
if not sub.yt_subscription_id:
|
if not sub.yt_subscription_id:
|
||||||
raise HTTPException(
|
raise refused(400, "no_subscription_id")
|
||||||
status_code=400,
|
|
||||||
detail="No YouTube subscription id on record — sync subscriptions first.",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ from app.audit import AuditAction
|
|||||||
from app.auth import admin_user
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
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"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
|
|
||||||
@@ -59,19 +62,16 @@ def set_config(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
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:
|
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():
|
if s.secret and not sysconfig.secrets_manageable():
|
||||||
raise HTTPException(
|
raise refused(400, "no_encryption_key")
|
||||||
status_code=400,
|
|
||||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
|
||||||
)
|
|
||||||
old = None if s.secret else sysconfig.get(db, key)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, body.value)
|
sysconfig.set_value(db, key, body.value)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
|
# 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
|
# 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
|
# 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:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
if s is None:
|
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)
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
sysconfig.reset(db, key)
|
sysconfig.reset(db, key)
|
||||||
audit.record(
|
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 —
|
"""Send a test email to the requesting admin using the current (DB or env) SMTP config —
|
||||||
lets the admin verify the settings actually work."""
|
lets the admin verify the settings actually work."""
|
||||||
if not email_mod.email_enabled():
|
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))
|
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||||
if not sent:
|
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}
|
return {"sent": True, "to": user.email}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ thing it exists to remove.
|
|||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
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 import pins, rails
|
||||||
from app.discover.budget import Budget, DEFAULT_BUDGET
|
from app.discover.budget import Budget, DEFAULT_BUDGET
|
||||||
from app.models import ComfortPin, User
|
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"])
|
router = APIRouter(prefix="/api/discover", tags=["discover"])
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ def add_pin(
|
|||||||
constraint is the mechanism, not the message."""
|
constraint is the mechanism, not the message."""
|
||||||
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
resolved = pins.resolve_ref(db, user, body.kind, body.ref)
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
column, value = resolved
|
column, value = resolved
|
||||||
|
|
||||||
existing = db.execute(
|
existing = db.execute(
|
||||||
@@ -157,7 +157,7 @@ def add_pin(
|
|||||||
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
|
# 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
|
# 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.
|
# 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": winner.id, "created": False}
|
||||||
return {"ok": True, "id": pin.id, "created": True}
|
return {"ok": True, "id": pin.id, "created": True}
|
||||||
|
|
||||||
|
|||||||
@@ -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.security import hash_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-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."""
|
as a raw URL for yt-dlp's generic extractor."""
|
||||||
raw = (raw or "").strip()
|
raw = (raw or "").strip()
|
||||||
if not raw:
|
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):
|
if _VIDEO_ID.match(raw):
|
||||||
return "youtube", raw
|
return "youtube", raw
|
||||||
if raw.startswith(("http://", "https://")):
|
if raw.startswith(("http://", "https://")):
|
||||||
@@ -93,7 +94,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
|
|||||||
if _VIDEO_ID.match(vid):
|
if _VIDEO_ID.match(vid):
|
||||||
return "youtube", vid
|
return "youtube", vid
|
||||||
return "url", raw
|
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:
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
@@ -233,7 +234,7 @@ def create_profile(
|
|||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
spec = body.spec
|
spec = body.spec
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||||
DownloadProfile.user_id == user.id
|
DownloadProfile.user_id == user.id
|
||||||
@@ -261,16 +262,16 @@ def update_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.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
|
sent = body.model_fields_set
|
||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="A profile name is required.")
|
raise refused(400, "profile_name_required")
|
||||||
p.name = name[:80]
|
p.name = name[:80]
|
||||||
if "spec" in sent:
|
if "spec" in sent:
|
||||||
if body.spec is None:
|
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
|
p.spec = body.spec
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_profile(p)
|
return _serialize_profile(p)
|
||||||
@@ -282,7 +283,7 @@ def delete_profile(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or p.is_builtin or p.user_id != user.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.delete(p)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": profile_id}
|
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
|
"""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."""
|
never echoed back, logged, or audited — only the fact that it changed."""
|
||||||
if not dlcreds.is_supported_host(host_key):
|
if not dlcreds.is_supported_host(host_key):
|
||||||
raise HTTPException(status_code=404, detail="Unknown host")
|
raise not_found("host")
|
||||||
try:
|
try:
|
||||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
except dlcreds.InvalidCookies as e:
|
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)
|
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not dlcreds.is_supported_host(host_key):
|
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):
|
if dlcreds.delete_credential(db, user.id, host_key):
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -355,7 +356,7 @@ def _resolve_spec(
|
|||||||
if profile_id is not None:
|
if profile_id is not None:
|
||||||
p = db.get(DownloadProfile, profile_id)
|
p = db.get(DownloadProfile, profile_id)
|
||||||
if p is None or (not p.is_builtin and p.user_id != user.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
|
return p.spec, p.id
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
return spec, None
|
return spec, None
|
||||||
@@ -366,7 +367,7 @@ def _resolve_spec(
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if fallback is 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
|
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 file is only read, never modified, so editing a shared video is safe."""
|
||||||
source_job = _accessible_job(db, user, body.source_job_id)
|
source_job = _accessible_job(db, user, body.source_job_id)
|
||||||
if source_job.status != "done" or source_job.asset_id is None:
|
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
|
spec = body.edit_spec
|
||||||
display_name = (body.display_name or "").strip() or None
|
display_name = (body.display_name or "").strip() or None
|
||||||
try:
|
try:
|
||||||
@@ -661,9 +662,9 @@ def redownload_download(
|
|||||||
if job.job_kind in ("edit", "upload"):
|
if job.job_kind in ("edit", "upload"):
|
||||||
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
|
# 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.
|
# 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"):
|
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)
|
service.redownload(db, job)
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
@@ -715,10 +716,10 @@ def download_file(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
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:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="File is no longer available.")
|
raise not_found("file")
|
||||||
|
|
||||||
filename = storage.download_filename(
|
filename = storage.download_filename(
|
||||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
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)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
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:
|
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)
|
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
||||||
if meta is None:
|
if meta is None:
|
||||||
return {"available": False}
|
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)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.poster_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -792,10 +793,10 @@ def storyboard_image(
|
|||||||
job = _accessible_job(db, user, job_id)
|
job = _accessible_job(db, user, job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
if asset is None or not asset.storyboard_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
raise not_found("filmstrip")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
@@ -815,10 +816,10 @@ def share_download(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
if job.status != "done" or job.asset_id is None:
|
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()
|
target = body.email.strip().lower()
|
||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=400, detail="A recipient email is required.")
|
raise refused(400, "recipient_required")
|
||||||
recipient = db.execute(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == target)
|
select(User).where(func.lower(User.email) == target)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -827,7 +828,7 @@ def share_download(
|
|||||||
if not is_messageable_user(recipient):
|
if not is_messageable_user(recipient):
|
||||||
raise not_found("recipient")
|
raise not_found("recipient")
|
||||||
if recipient.id == user.id:
|
if recipient.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
raise refused(400, "share_own")
|
||||||
exists = db.execute(
|
exists = db.execute(
|
||||||
select(DownloadShare.id).where(
|
select(DownloadShare.id).where(
|
||||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
||||||
@@ -972,7 +973,7 @@ def create_link(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||||
if job.status != "done" or job.asset_id is None:
|
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()
|
pw = body.password.strip()
|
||||||
link = DownloadLink(
|
link = DownloadLink(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date, datetime, timezone
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -29,10 +29,11 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
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.sync.videos import parse_iso8601_duration
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ def get_feed(
|
|||||||
vals, cursor_id = decode_cursor(keys, cursor)
|
vals, cursor_id = decode_cursor(keys, cursor)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
# `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):
|
for k, v in zip(keys, vals):
|
||||||
k["value"] = v
|
k["value"] = v
|
||||||
query = query.where(keyset_predicate(keys, cursor_id))
|
query = query.where(keyset_predicate(keys, cursor_id))
|
||||||
@@ -235,7 +236,7 @@ def set_video_state(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in VALID_STATES:
|
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")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
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
|
# 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.
|
# demo account can't reach it — it just sees "unknown" for a video we don't already store.
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
@@ -413,7 +414,7 @@ def get_video_detail(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc)
|
||||||
if not items:
|
if not items:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise not_found("video")
|
||||||
|
|
||||||
snippet = items[0].get("snippet", {})
|
snippet = items[0].get("snippet", {})
|
||||||
stats = items[0].get("statistics", {})
|
stats = items[0].get("statistics", {})
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from app import sysconfig
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/me", tags=["me"])
|
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."""
|
here) are allowed, and access is re-checked in case the invite was revoked since."""
|
||||||
target = body.user_id
|
target = body.user_id
|
||||||
if target not in (request.session.get("account_ids") or []):
|
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
|
# 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
|
# 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
|
# 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)
|
u = db.get(User, target)
|
||||||
if u is None:
|
if u is None:
|
||||||
drop_from_wallet(request.session, target)
|
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):
|
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:
|
if not u.is_active:
|
||||||
drop_from_wallet(request.session, target)
|
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:
|
if u.is_suspended:
|
||||||
drop_from_wallet(request.session, target)
|
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.
|
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
|
||||||
epochs = request.session.get("epochs") or {}
|
epochs = request.session.get("epochs") or {}
|
||||||
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||||
drop_from_wallet(request.session, target)
|
drop_from_wallet(request.session, target)
|
||||||
raise HTTPException(
|
raise refused(403, "account_signed_out")
|
||||||
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
|
||||||
)
|
|
||||||
request.session["user_id"] = target
|
request.session["user_id"] = target
|
||||||
return {"ok": True, "user_id": target}
|
return {"ok": True, "user_id": target}
|
||||||
|
|
||||||
@@ -181,10 +181,7 @@ def delete_my_account(
|
|||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
||||||
if user.role == "admin" and count_admins(db) <= 1:
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
raise HTTPException(
|
raise refused(400, "only_admin_delete")
|
||||||
status_code=400,
|
|
||||||
detail="You're the only admin — promote another admin before deleting your account.",
|
|
||||||
)
|
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
# 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.
|
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ routing and unread counts.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import and_, case, delete, func, or_, select
|
from sqlalchemy import and_, case, delete, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -29,6 +29,8 @@ from app.realtime import manager
|
|||||||
from app.security import verify_password
|
from app.security import verify_password
|
||||||
from app.userscope import is_messageable_user, messageable_clauses
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
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
|
"""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."""
|
key would orphan every existing conversation key, so re-setup is rejected here."""
|
||||||
if db.get(MessageKey, user.id) is not None:
|
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(
|
db.add(
|
||||||
MessageKey(
|
MessageKey(
|
||||||
user_id=user.id,
|
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
|
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
||||||
client-side danger confirmation."""
|
client-side danger confirmation."""
|
||||||
if not _reset_key_limiter.allow(str(user.id)):
|
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(
|
if user.password_hash and not verify_password(
|
||||||
payload.current_password or "", user.password_hash
|
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
|
# 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.
|
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -223,10 +225,10 @@ def public_key(
|
|||||||
p = db.get(User, user_id)
|
p = db.get(User, user_id)
|
||||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
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):
|
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)
|
k = db.get(MessageKey, user_id)
|
||||||
if k is None:
|
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}
|
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.
|
# 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))
|
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):
|
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)
|
partner = _serialize_user(p)
|
||||||
msgs = (
|
msgs = (
|
||||||
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
|
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
|
"""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)."""
|
produced, then pushes it live to the recipient (and the sender's other devices)."""
|
||||||
if not _send_limiter.allow(f"msg:{user.id}"):
|
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()
|
ct = (payload.ciphertext or "").strip()
|
||||||
iv = (payload.iv or "").strip()
|
iv = (payload.iv or "").strip()
|
||||||
if not ct or not iv:
|
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:
|
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:
|
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)
|
recipient = db.get(User, payload.recipient_id)
|
||||||
if not is_messageable_user(recipient):
|
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:
|
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)
|
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Shared helpers, serializers and constants for the plex route package."""
|
"""Shared helpers, serializers and constants for the plex route package."""
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.models import PlexItem
|
from app.models import PlexItem
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ log = logs.get_logger(__name__)
|
|||||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||||
it = by_rating_key(db, PlexItem, rating_key)
|
it = by_rating_key(db, PlexItem, rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
raise not_found("plex_item")
|
||||||
return it
|
return it
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import logs, sysconfig
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
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 import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
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()
|
info = plex.server_info()
|
||||||
sections = plex.sections()
|
sections = plex.sections()
|
||||||
media_check = _media_check(db, plex, sections)
|
media_check = _media_check(db, plex, sections)
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
except PlexError as e:
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
"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
|
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."""
|
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
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
|
enabled = body.enabled
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None:
|
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."""
|
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||||
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
link = db.execute(select(PlexLink).where(PlexLink.user_id == user.id)).scalars().first()
|
||||||
if link is None or not link.sync_enabled:
|
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)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
return {**_link_dict(link), "import": result}
|
return {**_link_dict(link), "import": result}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import logs
|
||||||
from app.auth import admin_user, current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
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.lookups import by_rating_key
|
||||||
from app.plex.query import content_libs, runtime_by_key, top_art_keys
|
from app.plex.query import content_libs, runtime_by_key, top_art_keys
|
||||||
from app.utils import like_escape
|
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
|
# 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).
|
# 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."""
|
endpoints below."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
movies = (
|
movies = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexItem)
|
select(PlexItem)
|
||||||
@@ -325,9 +330,9 @@ def collection_detail(
|
|||||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
if not _collection_editable(col):
|
if not _collection_editable(col):
|
||||||
raise HTTPException(status_code=403, detail="This collection is read-only")
|
raise refused(403, "collection_read_only")
|
||||||
return col
|
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
|
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
|
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."""
|
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||||
if wrote:
|
log.warning(
|
||||||
detail = (
|
"a Plex collection write did not settle",
|
||||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
extra=logs.event(
|
||||||
f"it will reconcile on the next sync ({e})"
|
"plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e)
|
||||||
)
|
),
|
||||||
else:
|
)
|
||||||
detail = f"Plex write failed: {e}"
|
return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed")
|
||||||
return HTTPException(status_code=422, detail=detail)
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCollectionIn(BaseModel):
|
class CreateCollectionIn(BaseModel):
|
||||||
@@ -389,7 +393,7 @@ def create_collection(
|
|||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
seed = body.item_rating_key
|
seed = body.item_rating_key
|
||||||
if not title or not seed:
|
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:
|
if library:
|
||||||
lib = (
|
lib = (
|
||||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
|
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)
|
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
|
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||||
if lib is None:
|
if lib is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown library")
|
raise not_found("library")
|
||||||
_reject_duplicate_name(db, lib.id, title)
|
_reject_duplicate_name(db, lib.id, title)
|
||||||
wrote = False
|
wrote = False
|
||||||
try:
|
try:
|
||||||
@@ -473,7 +477,7 @@ def collection_rename(
|
|||||||
col = _editable_collection_or_403(db, rating_key)
|
col = _editable_collection_or_403(db, rating_key)
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
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)
|
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||||
lib = db.get(PlexLibrary, col.library_id)
|
lib = db.get(PlexLibrary, col.library_id)
|
||||||
wrote = False
|
wrote = False
|
||||||
@@ -502,7 +506,7 @@ def collection_delete(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except (PlexError, PlexNotConfigured) as e:
|
except (PlexError, PlexNotConfigured) as e:
|
||||||
db.rollback()
|
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}
|
return {"deleted": rating_key}
|
||||||
|
|
||||||
|
|
||||||
@@ -521,13 +525,10 @@ def collection_set_editable(
|
|||||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
if col is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
raise not_found("collection")
|
||||||
want = body.editable
|
want = body.editable
|
||||||
if want and (col.smart or _collection_source(col) != "collection"):
|
if want and (col.smart or _collection_source(col) != "collection"):
|
||||||
raise HTTPException(
|
raise refused(400, "collection_smart")
|
||||||
status_code=400,
|
|
||||||
detail="Smart / auto-list collections can't be made editable",
|
|
||||||
)
|
|
||||||
col.editable = want
|
col.editable = want
|
||||||
db.commit()
|
db.commit()
|
||||||
return _collection_card(col)
|
return _collection_card(col)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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.plex.lookups import by_rating_key
|
||||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||||
from app.routes.plex.collections import _collection_source
|
from app.routes.plex.collections import _collection_source
|
||||||
|
from app.routes._common import not_found
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ def show_detail(
|
|||||||
hu = _norm_lang(lang) == "hu"
|
hu = _norm_lang(lang) == "hu"
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
seasons = (
|
seasons = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexSeason)
|
select(PlexSeason)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from app.plex import stream as plex_stream
|
|||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.plex._shared import item_or_404
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ def _require_session(request: Request) -> int:
|
|||||||
low-sensitivity; a valid session is enough."""
|
low-sensitivity; a valid session is enough."""
|
||||||
uid, _ = resolved_user_id(request)
|
uid, _ = resolved_user_id(request)
|
||||||
if not uid:
|
if not uid:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise refused(401, "not_authenticated")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
||||||
@@ -78,15 +80,17 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
|||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
key = imgcache.key_for(db, str(rating_key), v)
|
key = imgcache.key_for(db, str(rating_key), v)
|
||||||
if not key:
|
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
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise refused(400, "plex_not_configured") from None
|
||||||
try:
|
try:
|
||||||
with plex:
|
with plex:
|
||||||
data, ct = plex.image_bytes(key, w, h)
|
data, ct = plex.image_bytes(key, w, h)
|
||||||
except PlexError as e:
|
# silent-ok: a byte proxy for an <img>; the browser shows the placeholder and no reader sees the
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
# 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)
|
imgcache.write(cache_key, data, ct)
|
||||||
return imgcache.response(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."""
|
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
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()
|
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||||
hit = imgcache.read(cache_key)
|
hit = imgcache.read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
@@ -224,7 +228,7 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
r = c.get(u)
|
r = c.get(u)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except httpx.HTTPError:
|
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")
|
ct = r.headers.get("content-type", "image/jpeg")
|
||||||
imgcache.write(cache_key, r.content, ct)
|
imgcache.write(cache_key, r.content, ct)
|
||||||
return imgcache.response(r.content, ct)
|
return imgcache.response(r.content, ct)
|
||||||
@@ -444,7 +448,7 @@ def item_state(
|
|||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
status = body.status
|
status = body.status
|
||||||
if status not in ("new", "watched", "hidden"):
|
if status not in ("new", "watched", "hidden"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise refused(400, "invalid_status")
|
||||||
st = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
|
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."""
|
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
sh = by_rating_key(db, PlexShow, rating_key)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise not_found("show")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode"))
|
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."""
|
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
se = by_rating_key(db, PlexSeason, rating_key)
|
se = by_rating_key(db, PlexSeason, rating_key)
|
||||||
if se is None:
|
if se is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
raise not_found("season")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = (
|
eps = (
|
||||||
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
db.execute(select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode"))
|
||||||
@@ -606,10 +610,10 @@ def stream_session(
|
|||||||
"start_s": 0.0,
|
"start_s": 0.0,
|
||||||
}
|
}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
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)
|
s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode)
|
||||||
if s is None:
|
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
|
# 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.
|
# 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
|
# `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)
|
it = item_or_404(db, rating_key)
|
||||||
src = plex_paths.local_media_path(db, it.file_path)
|
src = plex_paths.local_media_path(db, it.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise refused(404, "media_missing")
|
||||||
if download:
|
if download:
|
||||||
# A clean, safe download name from the title + the real file extension.
|
# A clean, safe download name from the title + the real file extension.
|
||||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
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
|
||||||
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))
|
s = plex_stream.current_session(user.id, str(rating_key))
|
||||||
if s is None or s.id != session_id:
|
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
|
path = s.dir / filename
|
||||||
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
# 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
|
# 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
|
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
||||||
# workers and their DB connections (see `artifact_may_arrive`).
|
# workers and their DB connections (see `artifact_may_arrive`).
|
||||||
if not plex_stream.artifact_may_arrive(s, filename):
|
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
|
timeout = 30 if ext == ".m3u8" else 20
|
||||||
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
# `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
|
# 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
|
# 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).
|
# past the produced region was enough to do it (round 5).
|
||||||
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
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:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -725,7 +729,7 @@ def stream_hls_file(
|
|||||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
# 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
|
# 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.
|
# 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(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=_HLS_CT[ext],
|
media_type=_HLS_CT[ext],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -19,7 +19,8 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex.cards import leaf_card
|
from app.plex.cards import leaf_card
|
||||||
from app.plex.lookups import by_rating_key
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ def create_playlist(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
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)
|
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -182,7 +183,7 @@ def rename_playlist(
|
|||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
title = body.title.strip()
|
title = body.title.strip()
|
||||||
if not title:
|
if not title:
|
||||||
raise HTTPException(status_code=400, detail="title is required")
|
raise refused(400, "title_required")
|
||||||
pl.title = title
|
pl.title = title
|
||||||
db.commit()
|
db.commit()
|
||||||
count = (
|
count = (
|
||||||
@@ -219,7 +220,7 @@ def playlist_add_item(
|
|||||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown item")
|
raise not_found("item")
|
||||||
exists = (
|
exists = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(PlexPlaylistItem).where(
|
select(PlexPlaylistItem).where(
|
||||||
|
|||||||
@@ -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).
|
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 fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -19,6 +19,8 @@ from app.downloads import storage
|
|||||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||||
from app.ratelimit import RateLimiter, client_key
|
from app.ratelimit import RateLimiter, client_key
|
||||||
from app.security import verify_password
|
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"])
|
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:
|
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||||
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
link = db.execute(select(DownloadLink).where(DownloadLink.token == token)).scalar_one_or_none()
|
||||||
if link is None or linksmod.is_expired(link):
|
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
|
return link
|
||||||
|
|
||||||
|
|
||||||
@@ -45,12 +47,12 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
|||||||
job = db.get(DownloadJob, link.job_id)
|
job = db.get(DownloadJob, link.job_id)
|
||||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
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:
|
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
|
# 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.
|
# loads a player for a missing file — meta and file agree.
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
raise refused(404, "video_gone")
|
||||||
return job, asset
|
return job, asset
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ def watch_unlock(
|
|||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
viewer = f"{token}|{client_key(request)}"
|
viewer = f"{token}|{client_key(request)}"
|
||||||
if _unlock_failures.exhausted(token) or not _unlock_limit.allow(viewer):
|
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:
|
if not link.password_hash:
|
||||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
@@ -110,7 +112,7 @@ def watch_unlock(
|
|||||||
}
|
}
|
||||||
if not verify_password(body.password, link.password_hash):
|
if not verify_password(body.password, link.password_hash):
|
||||||
_unlock_failures.allow(token)
|
_unlock_failures.allow(token)
|
||||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
raise refused(403, "wrong_share_password")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
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."""
|
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
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)
|
asset = _ready_asset(db, link)
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||||
if path is None:
|
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)
|
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
|
# 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."""
|
and the link-preview image). Password links require a valid grant, like the file."""
|
||||||
link = _link_or_404(db, token)
|
link = _link_or_404(db, token)
|
||||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
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)
|
job, asset = _ready_target(db, link)
|
||||||
if not asset.poster_path:
|
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)
|
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=404, detail="No poster.")
|
raise not_found("poster")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -8,6 +8,7 @@ from app.auth import require_human
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import SavedView, User
|
from app.models import SavedView, User
|
||||||
from app.routes._common import owned_or_404
|
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
|
# 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
|
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||||
@@ -66,7 +67,7 @@ def create_view(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise refused(400, "name_required")
|
||||||
source = _norm_source(body.source)
|
source = _norm_source(body.source)
|
||||||
# Position is per (user, source), so each module's views number 0..N independently.
|
# Position is per (user, source), so each module's views number 0..N independently.
|
||||||
maxpos = db.execute(
|
maxpos = db.execute(
|
||||||
@@ -86,7 +87,7 @@ def create_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +111,11 @@ def update_view(
|
|||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
view.name = name[:80]
|
view.name = name[:80]
|
||||||
if "filters" in sent:
|
if "filters" in sent:
|
||||||
if body.filters is None:
|
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
|
view.filters = body.filters
|
||||||
if "is_default" in sent:
|
if "is_default" in sent:
|
||||||
make_default = bool(body.is_default)
|
make_default = bool(body.is_default)
|
||||||
@@ -134,7 +135,7 @@ def update_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
|
||||||
and the shared quota picture."""
|
and the shared quota picture."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -25,6 +25,8 @@ from app.scheduler import (
|
|||||||
from app.sync.explore import followed_channel
|
from app.sync.explore import followed_channel
|
||||||
from app.sync.runner import estimate_deep_backfill
|
from app.sync.runner import estimate_deep_backfill
|
||||||
from app.routes._common import not_found
|
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"])
|
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||||
|
|
||||||
@@ -46,10 +48,7 @@ def set_job_interval(
|
|||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
minutes = body.interval_minutes
|
minutes = body.interval_minutes
|
||||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||||
raise HTTPException(
|
raise refused(400, "invalid_interval", min=MIN_INTERVAL, max=MAX_INTERVAL)
|
||||||
status_code=400,
|
|
||||||
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
|
||||||
)
|
|
||||||
applied = apply_interval(db, job_id, minutes)
|
applied = apply_interval(db, job_id, minutes)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -77,7 +76,7 @@ def run_job_now(
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
if result == "already_running":
|
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}
|
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.
|
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||||
except (TypeError, ValueError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
raise setting_refused(exc) from None
|
||||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ dropped from the search response up front)."""
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import and_, delete, func, or_, select, update
|
from sqlalchemy import and_, delete, func, or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
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 import search_scrape
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.youtube.shorts import make_client, probe_is_short
|
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__)
|
log = logs.get_logger(__name__)
|
||||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
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."""
|
page, so it returns a single page (≤50) regardless. `cursor` lets the caller fetch further."""
|
||||||
term = (q or "").strip()
|
term = (q or "").strip()
|
||||||
if not term:
|
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))
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
# 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",
|
"a live search was refused: the per-user daily cap is spent",
|
||||||
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise refused(429, "search_daily_limit", limit=daily_limit)
|
||||||
status_code=429,
|
|
||||||
detail=f"You've reached today's YouTube search limit ({daily_limit}). Try again tomorrow.",
|
|
||||||
)
|
|
||||||
# The 100-unit budget pre-check only applies to the API source.
|
# The 100-unit budget pre-check only applies to the API source.
|
||||||
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||||
log.info(
|
log.info(
|
||||||
"a live search was refused: the shared YouTube budget is spent",
|
"a live search was refused: the shared YouTube budget is spent",
|
||||||
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise refused(429, "quota_spent")
|
||||||
status_code=429,
|
|
||||||
detail="The shared YouTube quota for today is used up. Try again tomorrow.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||||
blocked = set(
|
blocked = set(
|
||||||
@@ -313,9 +309,14 @@ def search_youtube(
|
|||||||
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
# 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
|
# 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)
|
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
||||||
raise HTTPException(
|
# A Data API failure is logged where it happened; the scraper's is not, and its text
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
# no longer rides the answer — so it is said here.
|
||||||
) from exc
|
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.
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -357,9 +358,7 @@ def search_youtube(
|
|||||||
)
|
)
|
||||||
if collected:
|
if collected:
|
||||||
break # keep what earlier pages already gathered
|
break # keep what earlier pages already gathered
|
||||||
raise HTTPException(
|
raise youtube_refusal(exc) from exc
|
||||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
|
||||||
) from exc
|
|
||||||
next_cursor = page["next_page_token"]
|
next_cursor = page["next_page_token"]
|
||||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
# 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:
|
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||||
|
|||||||
+13
-13
@@ -8,7 +8,7 @@ X-Setup-Token header).
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -21,6 +21,8 @@ from app.auth import PASSWORD_MIN_LEN
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
|
from app.refusal import refused
|
||||||
|
from app.refusal import setting_refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
@@ -31,9 +33,9 @@ def require_setup(
|
|||||||
) -> Session:
|
) -> Session:
|
||||||
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
||||||
if state.is_configured(db):
|
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):
|
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
|
return db
|
||||||
|
|
||||||
|
|
||||||
@@ -66,11 +68,9 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
|
|||||||
email = body.email.strip().lower()
|
email = body.email.strip().lower()
|
||||||
password = body.password
|
password = body.password
|
||||||
if "@" not in email:
|
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:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
|
||||||
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
|
||||||
)
|
|
||||||
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
# 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
|
# 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
|
# 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] = []
|
saved: list[str] = []
|
||||||
for key, value in settings_map.items():
|
for key, value in settings_map.items():
|
||||||
if sysconfig.spec(key) is None:
|
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 == "":
|
if value is None or value == "":
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, value)
|
sysconfig.set_value(db, key, value)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except sysconfig.InvalidSetting as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise setting_refused(exc) from None
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||||
return {"saved": saved}
|
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."""
|
"""Send a test email to confirm the SMTP settings the wizard just saved."""
|
||||||
to = body.to.strip()
|
to = body.to.strip()
|
||||||
if "@" not in to:
|
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)):
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +133,6 @@ def setup_finish(db: Session = Depends(require_setup)) -> dict:
|
|||||||
invalidates the setup token and disables the wizard)."""
|
invalidates the setup token and disables the wizard)."""
|
||||||
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
|
||||||
if not has_admin:
|
if not has_admin:
|
||||||
raise HTTPException(status_code=400, detail="Create the admin account first.")
|
raise refused(400, "admin_first")
|
||||||
state.mark_configured(db)
|
state.mark_configured(db)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.sync.runner import estimate_deep_backfill
|
|||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.scheduler import running_job_ids
|
from app.scheduler import running_job_ids
|
||||||
from app.youtube.client import YouTubeError
|
from app.youtube.client import YouTubeError
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
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:
|
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
if not has_read_scope(user):
|
if not has_read_scope(user):
|
||||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||||
raise HTTPException(
|
raise refused(403, "youtube_read_scope")
|
||||||
status_code=403,
|
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
|
||||||
)
|
|
||||||
if not _sync_limiter.allow(str(user.id)):
|
if not _sync_limiter.allow(str(user.id)):
|
||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -9,6 +9,7 @@ from app.db import get_db
|
|||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.sync.tagsuggest import compute_tag_suggestions
|
from app.sync.tagsuggest import compute_tag_suggestions
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def create_tag(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
if not name:
|
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.
|
# 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"
|
category = body.category if body.category in ("language", "topic", "other") else "other"
|
||||||
tag = Tag(
|
tag = Tag(
|
||||||
@@ -74,7 +75,7 @@ def create_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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}
|
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ def update_tag(
|
|||||||
if "name" in sent:
|
if "name" in sent:
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
raise refused(400, "name_required")
|
||||||
tag.name = name
|
tag.name = name
|
||||||
if "color" in sent:
|
if "color" in sent:
|
||||||
tag.color = body.color
|
tag.color = body.color
|
||||||
@@ -106,7 +107,7 @@ def update_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
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}
|
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -212,11 +212,23 @@ def _default(s: ConfigSpec):
|
|||||||
return getattr(settings, s.default_attr)
|
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:
|
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)
|
option = canonical_choice(s.choices, raw)
|
||||||
if option is None:
|
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
|
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."""
|
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
|
||||||
s = _BY_KEY[key]
|
s = _BY_KEY[key]
|
||||||
if s.type == "int":
|
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:
|
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:
|
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)
|
raw = str(v)
|
||||||
elif s.type == "bool":
|
elif s.type == "bool":
|
||||||
raw = "true" if value else "false"
|
raw = "true" if value else "false"
|
||||||
@@ -322,7 +337,7 @@ def set_value(db: Session, key: str, value) -> None:
|
|||||||
stored, encrypted = raw, False
|
stored, encrypted = raw, False
|
||||||
if s.secret:
|
if s.secret:
|
||||||
if not secrets_manageable():
|
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
|
stored, encrypted = security.encrypt(raw) or "", True
|
||||||
|
|
||||||
row = db.get(SystemConfig, key)
|
row = db.get(SystemConfig, key)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def test_malformed_feed_cursor_returns_400(db, monkeypatch):
|
|||||||
db.commit()
|
db.commit()
|
||||||
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
|
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
|
||||||
assert resp.status_code == 400
|
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):
|
def test_feed_without_cursor_is_200(db, monkeypatch):
|
||||||
|
|||||||
@@ -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"},
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 422, r.text
|
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
|
assert "create_collection" in stub.calls
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
@@ -361,7 +361,7 @@ def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
|||||||
try:
|
try:
|
||||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||||
assert r.status_code == 422, r.text
|
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:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeyp
|
|||||||
try:
|
try:
|
||||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||||
assert r.status_code == 422, r.text
|
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
|
assert "rename_collection" in stub.calls # the write DID happen
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -153,4 +153,4 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
assert resp.status_code == 422, resp.text
|
assert resp.status_code == 422, resp.text
|
||||||
assert "YouTube search failed" in resp.json()["detail"]
|
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ const networkProperties = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// A `//` or `/* */` inside JSX children is not a comment — it is TEXT, rendered on the page. One
|
||||||
|
// slipped in when a return was wrapped in a fragment (Playlists.tsx) and only a visual check caught
|
||||||
|
// it: tsc, eslint and every test stayed green. `eslint-plugin-react` has this rule, but it would bring
|
||||||
|
// ~1800 lockfile lines of dependencies for ten lines of logic, so it lives here.
|
||||||
|
const jsxCommentText = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: { text: "A comment inside JSX children renders as text. Wrap it in {/* */}." },
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
return {
|
||||||
|
JSXText(node) {
|
||||||
|
if (/^\s*(\/\/|\/\*)/m.test(node.value)) context.report({ node, messageId: "text" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||||
{
|
{
|
||||||
@@ -56,12 +75,14 @@ export default tseslint.config(
|
|||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
|
local: localRules,
|
||||||
},
|
},
|
||||||
linterOptions: {
|
linterOptions: {
|
||||||
reportUnusedDisableDirectives: "error",
|
reportUnusedDisableDirectives: "error",
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"local/jsx-comment-text": "error",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ const goneWhats = [
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
||||||
|
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
||||||
|
// `setting_refused` picks from at runtime.
|
||||||
|
const appSources = (dir = ""): string[] =>
|
||||||
|
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||||
|
withFileTypes: true,
|
||||||
|
}).flatMap((e) =>
|
||||||
|
e.isDirectory()
|
||||||
|
? appSources(`${dir}${e.name}/`)
|
||||||
|
: e.name.endsWith(".py")
|
||||||
|
? [backend(`${dir}${e.name}`)]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const refusedReasons = [
|
||||||
|
...new Set([
|
||||||
|
...appSources().flatMap((src) =>
|
||||||
|
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
||||||
|
),
|
||||||
|
...[
|
||||||
|
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
||||||
|
/:\s*"(\w+)"/g,
|
||||||
|
),
|
||||||
|
].map((m) => m[1]!),
|
||||||
|
]),
|
||||||
|
];
|
||||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
||||||
const youtubeReasons = [
|
const youtubeReasons = [
|
||||||
...(
|
...(
|
||||||
@@ -134,6 +159,9 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable"]);
|
||||||
|
expect(refusedReasons).toEqual(
|
||||||
|
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
||||||
|
);
|
||||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
// Every code the frontend localizes is raised somewhere the scan can see.
|
||||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -200,6 +228,13 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
|
||||||
|
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
|
||||||
|
expect(
|
||||||
|
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it(`${lang}: every admin refusal reason`, () => {
|
it(`${lang}: every admin refusal reason`, () => {
|
||||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||||
|
|||||||
@@ -21,7 +21,21 @@
|
|||||||
"job": "This job doesn't exist any more.",
|
"job": "This job doesn't exist any more.",
|
||||||
"download": "This download doesn't exist any more.",
|
"download": "This download doesn't exist any more.",
|
||||||
"link": "This share link doesn't exist any more.",
|
"link": "This share link doesn't exist any more.",
|
||||||
"notification": "This notification doesn't exist any more."
|
"notification": "This notification doesn't exist any more.",
|
||||||
|
"account": "That account no longer exists.",
|
||||||
|
"collection": "This collection doesn't exist any more.",
|
||||||
|
"config_key": "There's no such setting.",
|
||||||
|
"file": "The file is no longer available.",
|
||||||
|
"filmstrip": "There's no filmstrip for this.",
|
||||||
|
"host": "This host isn't on the list any more.",
|
||||||
|
"image": "There's no image for this.",
|
||||||
|
"item": "This item doesn't exist any more.",
|
||||||
|
"library": "This library doesn't exist any more.",
|
||||||
|
"plex_item": "This Plex item isn't in the library any more.",
|
||||||
|
"poster": "There's no poster for this.",
|
||||||
|
"profile": "This download profile doesn't exist any more.",
|
||||||
|
"season": "This season isn't in the library any more.",
|
||||||
|
"show": "This show isn't in the library any more."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
|
"not_allowed": "Their address isn't approved any more. Add it to the whitelist first.",
|
||||||
@@ -99,5 +113,98 @@
|
|||||||
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
|
"quota": "Today's YouTube budget for this instance is spent. It resets at midnight Pacific time — try again then.",
|
||||||
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
|
"reconnect": "Google no longer accepts this account's YouTube connection. Sign out and sign in with Google again to reconnect it.",
|
||||||
"unavailable": "YouTube didn't accept that just now. Please try again."
|
"unavailable": "YouTube didn't accept that just now. Please try again."
|
||||||
|
},
|
||||||
|
"refused": {
|
||||||
|
"account_no_access": "That account no longer has access.",
|
||||||
|
"account_signed_out": "That account was signed out elsewhere — sign in again.",
|
||||||
|
"account_suspended": "That account is suspended.",
|
||||||
|
"address_has_account": "That address already has an account — delete the account instead.",
|
||||||
|
"admin_first": "Create the admin account first.",
|
||||||
|
"admin_only": "Only an admin can do that.",
|
||||||
|
"already_set_up": "This instance is already set up.",
|
||||||
|
"already_subscribed": "You're already subscribed to this channel.",
|
||||||
|
"channel_blocked": "You've blocked this channel.",
|
||||||
|
"collection_fields_required": "A title and an item are both needed.",
|
||||||
|
"collection_pending_sync": "The collection changed in Plex, but refreshing the local copy failed — it catches up on the next sync.",
|
||||||
|
"collection_read_only": "This collection is read-only.",
|
||||||
|
"collection_smart": "Smart and auto-list collections can't be made editable.",
|
||||||
|
"demo_activate": "The demo account can't be activated.",
|
||||||
|
"demo_delete": "The demo account can't be deleted.",
|
||||||
|
"demo_role": "The demo account's role can't be changed.",
|
||||||
|
"demo_suspend": "The demo account can't be suspended.",
|
||||||
|
"download_not_ready": "This download isn't ready.",
|
||||||
|
"edit_unfinished": "Only a finished download can be edited.",
|
||||||
|
"filters_not_object": "The view's filters are malformed.",
|
||||||
|
"google_no_userinfo": "Google didn't send back the account details. Try signing in again.",
|
||||||
|
"image_fetch_failed": "The image couldn't be loaded.",
|
||||||
|
"image_host": "Images from that address aren't loaded.",
|
||||||
|
"invalid_cursor": "The list position is out of date — reload the page.",
|
||||||
|
"invalid_email": "Enter a valid email address.",
|
||||||
|
"invalid_interval": "The interval must be between {{min}} and {{max}} minutes.",
|
||||||
|
"invalid_link": "That doesn't look like a valid video link.",
|
||||||
|
"invalid_role": "Unknown role.",
|
||||||
|
"invalid_status": "Unknown status.",
|
||||||
|
"invite_decided": "This request has already been decided.",
|
||||||
|
"job_running": "This job is already running.",
|
||||||
|
"last_active_admin": "This is the only active admin — make someone else admin first.",
|
||||||
|
"last_admin": "This is the only admin — make someone else admin first.",
|
||||||
|
"link_gone": "This link is no longer available.",
|
||||||
|
"link_required": "A video link or id is required.",
|
||||||
|
"media_missing": "The media file isn't on disk.",
|
||||||
|
"message_empty": "The message is empty.",
|
||||||
|
"message_rate": "Too many messages — slow down a moment.",
|
||||||
|
"message_self": "You can't message yourself.",
|
||||||
|
"message_too_long": "The message is too long.",
|
||||||
|
"messaging_set_up": "Secure messaging is already set up.",
|
||||||
|
"name_required": "A name is required.",
|
||||||
|
"no_encryption_key": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||||
|
"no_messaging": "That member hasn't set up messaging yet.",
|
||||||
|
"no_profile": "There's no download profile to use.",
|
||||||
|
"no_subscription_id": "There's no YouTube subscription on record yet — sync your subscriptions first.",
|
||||||
|
"not_authenticated": "You're not signed in.",
|
||||||
|
"not_ready": "Not available yet.",
|
||||||
|
"not_redownloadable": "This item can't be downloaded again.",
|
||||||
|
"not_your_account": "That's not an account you've signed into here.",
|
||||||
|
"only_admin_delete": "You're the only admin — make someone else admin before deleting your account.",
|
||||||
|
"own_delete": "Delete your own account in Settings → Account.",
|
||||||
|
"own_role": "You can't change your own role.",
|
||||||
|
"own_suspend": "You can't suspend your own account.",
|
||||||
|
"plex_disabled": "The Plex module is turned off.",
|
||||||
|
"plex_not_configured": "Plex isn't set up — add the server address and token in Configuration → Plex.",
|
||||||
|
"plex_unreachable": "The Plex server didn't answer. Check the address and the token, then try again.",
|
||||||
|
"plex_write_failed": "Plex didn't accept the change. Try again.",
|
||||||
|
"profile_name_required": "A profile name is required.",
|
||||||
|
"quota_reserve": "Today's shared YouTube budget is down to its reserve — try again later.",
|
||||||
|
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
|
||||||
|
"recipient_required": "A recipient email is required.",
|
||||||
|
"registration_closed": "Registration is currently closed.",
|
||||||
|
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
|
||||||
|
"search_term_required": "Enter something to search for.",
|
||||||
|
"session_gone": "The playback session ended.",
|
||||||
|
"setting_not_a_choice": "Pick one of: {{choices}}.",
|
||||||
|
"setting_not_a_number": "Enter a whole number.",
|
||||||
|
"setting_too_high": "The value can be at most {{max}}.",
|
||||||
|
"setting_too_low": "The value must be at least {{min}}.",
|
||||||
|
"setup_token": "The setup token is missing or wrong.",
|
||||||
|
"share_own": "That's already your download.",
|
||||||
|
"share_password_required": "This link needs a password.",
|
||||||
|
"share_unfinished": "Only a finished download can be shared.",
|
||||||
|
"smtp_failed": "The email couldn't be sent — check the SMTP settings.",
|
||||||
|
"smtp_off": "SMTP isn't configured.",
|
||||||
|
"spec_not_object": "The profile's settings are malformed.",
|
||||||
|
"still_downloading": "This download is still in progress.",
|
||||||
|
"tag_name_taken": "You already have a tag named “{{name}}”.",
|
||||||
|
"title_required": "A title is required.",
|
||||||
|
"too_many_attempts": "Too many attempts. Try again shortly.",
|
||||||
|
"unknown_setting": "Unknown setting: {{key}}",
|
||||||
|
"value_missing": "A value is required.",
|
||||||
|
"video_gone": "This video is no longer available.",
|
||||||
|
"view_name_taken": "You already have a view named “{{name}}”.",
|
||||||
|
"watch_sync_off": "Turn on Plex watch-sync first.",
|
||||||
|
"weak_password": "The password must be at least {{min}} characters.",
|
||||||
|
"wrong_password": "The current password is incorrect.",
|
||||||
|
"wrong_share_password": "Wrong password.",
|
||||||
|
"youtube_editing_off": "Turn on YouTube editing in Settings first.",
|
||||||
|
"youtube_read_scope": "Connect your YouTube account (read access) first."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,21 @@
|
|||||||
"job": "Ez a feladat már nem létezik.",
|
"job": "Ez a feladat már nem létezik.",
|
||||||
"download": "Ez a letöltés már nem létezik.",
|
"download": "Ez a letöltés már nem létezik.",
|
||||||
"link": "Ez a megosztási link már nem létezik.",
|
"link": "Ez a megosztási link már nem létezik.",
|
||||||
"notification": "Ez az értesítés már nem létezik."
|
"notification": "Ez az értesítés már nem létezik.",
|
||||||
|
"account": "Ez a fiók már nem létezik.",
|
||||||
|
"collection": "Ez a gyűjtemény már nem létezik.",
|
||||||
|
"config_key": "Nincs ilyen beállítás.",
|
||||||
|
"file": "A fájl már nem érhető el.",
|
||||||
|
"filmstrip": "Ehhez nincs képsor.",
|
||||||
|
"host": "Ez a kiszolgáló már nincs a listán.",
|
||||||
|
"image": "Ehhez nincs kép.",
|
||||||
|
"item": "Ez az elem már nem létezik.",
|
||||||
|
"library": "Ez a könyvtár már nem létezik.",
|
||||||
|
"plex_item": "Ez a Plex-elem már nincs a könyvtárban.",
|
||||||
|
"poster": "Ehhez nincs poszter.",
|
||||||
|
"profile": "Ez a letöltési profil már nem létezik.",
|
||||||
|
"season": "Ez az évad már nincs a könyvtárban.",
|
||||||
|
"show": "Ez a sorozat már nincs a könyvtárban."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
|
"not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.",
|
||||||
@@ -99,5 +113,98 @@
|
|||||||
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
|
"quota": "Ennek a példánynak elfogyott a mai YouTube-kerete. Csendes-óceáni idő szerint éjfélkor újraindul — akkor próbáld újra.",
|
||||||
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
|
"reconnect": "A Google már nem fogadja el ennek a fióknak a YouTube-kapcsolatát. Jelentkezz ki, majd lépj be újra Google-lel az összekötéshez.",
|
||||||
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
|
"unavailable": "A YouTube most nem fogadta el. Próbáld újra."
|
||||||
|
},
|
||||||
|
"refused": {
|
||||||
|
"account_no_access": "Ennek a fióknak már nincs hozzáférése.",
|
||||||
|
"account_signed_out": "Ezt a fiókot máshol kijelentkeztették — lépj be újra.",
|
||||||
|
"account_suspended": "Ez a fiók fel van függesztve.",
|
||||||
|
"address_has_account": "Ehhez a címhez már tartozik fiók — azt töröld helyette.",
|
||||||
|
"admin_first": "Előbb hozd létre az admin fiókot.",
|
||||||
|
"admin_only": "Ezt csak admin teheti meg.",
|
||||||
|
"already_set_up": "Ez a példány már be van állítva.",
|
||||||
|
"already_subscribed": "Erre a csatornára már feliratkoztál.",
|
||||||
|
"channel_blocked": "Ezt a csatornát letiltottad.",
|
||||||
|
"collection_fields_required": "A címet és egy elemet is meg kell adni.",
|
||||||
|
"collection_pending_sync": "A gyűjtemény a Plexben megváltozott, de a helyi másolat frissítése nem sikerült — a következő szinkron pótolja.",
|
||||||
|
"collection_read_only": "Ez a gyűjtemény csak olvasható.",
|
||||||
|
"collection_smart": "Az okos és az automatikus listás gyűjtemények nem tehetők szerkeszthetővé.",
|
||||||
|
"demo_activate": "A bemutató fiók nem aktiválható.",
|
||||||
|
"demo_delete": "A bemutató fiók nem törölhető.",
|
||||||
|
"demo_role": "A bemutató fiók szerepköre nem módosítható.",
|
||||||
|
"demo_suspend": "A bemutató fiók nem függeszthető fel.",
|
||||||
|
"download_not_ready": "Ez a letöltés még nincs kész.",
|
||||||
|
"edit_unfinished": "Csak befejezett letöltés szerkeszthető.",
|
||||||
|
"filters_not_object": "A nézet szűrői hibás formátumúak.",
|
||||||
|
"google_no_userinfo": "A Google nem küldte vissza a fiók adatait. Próbálj újra belépni.",
|
||||||
|
"image_fetch_failed": "A képet nem sikerült betölteni.",
|
||||||
|
"image_host": "Erről a címről nem töltünk be képet.",
|
||||||
|
"invalid_cursor": "A lista pozíciója elavult — töltsd újra az oldalt.",
|
||||||
|
"invalid_email": "Adj meg egy érvényes e-mail címet.",
|
||||||
|
"invalid_interval": "Az intervallumnak {{min}} és {{max}} perc között kell lennie.",
|
||||||
|
"invalid_link": "Ez nem tűnik érvényes videólinknek.",
|
||||||
|
"invalid_role": "Ismeretlen szerepkör.",
|
||||||
|
"invalid_status": "Ismeretlen állapot.",
|
||||||
|
"invite_decided": "Erről a kérésről már döntöttek.",
|
||||||
|
"job_running": "Ez a feladat már fut.",
|
||||||
|
"last_active_admin": "Ez az egyetlen aktív admin — előbb tegyél meg valaki mást adminnak.",
|
||||||
|
"last_admin": "Ez az egyetlen admin — előbb tegyél meg valaki mást adminnak.",
|
||||||
|
"link_gone": "Ez a link már nem érhető el.",
|
||||||
|
"link_required": "Adj meg egy videólinket vagy -azonosítót.",
|
||||||
|
"media_missing": "A médiafájl nincs a lemezen.",
|
||||||
|
"message_empty": "Az üzenet üres.",
|
||||||
|
"message_rate": "Túl sok üzenet — várj egy kicsit.",
|
||||||
|
"message_self": "Magadnak nem küldhetsz üzenetet.",
|
||||||
|
"message_too_long": "Az üzenet túl hosszú.",
|
||||||
|
"messaging_set_up": "A biztonságos üzenetküldés már be van állítva.",
|
||||||
|
"name_required": "Adj meg egy nevet.",
|
||||||
|
"no_encryption_key": "Titkos értékek tárolásához állítsd be a TOKEN_ENCRYPTION_KEY-t.",
|
||||||
|
"no_messaging": "Ez a tag még nem állította be az üzenetküldést.",
|
||||||
|
"no_profile": "Nincs használható letöltési profil.",
|
||||||
|
"no_subscription_id": "Még nincs rögzített YouTube-feliratkozás — előbb szinkronizáld a feliratkozásaidat.",
|
||||||
|
"not_authenticated": "Nem vagy bejelentkezve.",
|
||||||
|
"not_ready": "Még nem érhető el.",
|
||||||
|
"not_redownloadable": "Ez az elem nem tölthető le újra.",
|
||||||
|
"not_your_account": "Ebbe a fiókba itt nem léptél be.",
|
||||||
|
"only_admin_delete": "Te vagy az egyetlen admin — a fiókod törlése előtt tegyél meg valaki mást adminnak.",
|
||||||
|
"own_delete": "A saját fiókodat a Beállítások → Fiók alatt törölheted.",
|
||||||
|
"own_role": "A saját szerepkörödet nem módosíthatod.",
|
||||||
|
"own_suspend": "A saját fiókodat nem függesztheted fel.",
|
||||||
|
"plex_disabled": "A Plex modul ki van kapcsolva.",
|
||||||
|
"plex_not_configured": "A Plex nincs beállítva — add meg a szerver címét és a tokent a Konfiguráció → Plex alatt.",
|
||||||
|
"plex_unreachable": "A Plex-szerver nem válaszolt. Ellenőrizd a címet és a tokent, majd próbáld újra.",
|
||||||
|
"plex_write_failed": "A Plex nem fogadta el a módosítást. Próbáld újra.",
|
||||||
|
"profile_name_required": "Adj meg egy profilnevet.",
|
||||||
|
"quota_reserve": "A mai közös YouTube-keret a tartalékánál tart — próbáld később.",
|
||||||
|
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
|
||||||
|
"recipient_required": "Adj meg egy címzett e-mail címet.",
|
||||||
|
"registration_closed": "A regisztráció jelenleg zárva van.",
|
||||||
|
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
|
||||||
|
"search_term_required": "Írj be egy keresőkifejezést.",
|
||||||
|
"session_gone": "A lejátszási munkamenet véget ért.",
|
||||||
|
"setting_not_a_choice": "Válassz ezek közül: {{choices}}.",
|
||||||
|
"setting_not_a_number": "Adj meg egy egész számot.",
|
||||||
|
"setting_too_high": "Az érték nem lehet nagyobb, mint {{max}}.",
|
||||||
|
"setting_too_low": "Az érték nem lehet kisebb, mint {{min}}.",
|
||||||
|
"setup_token": "A beállítási token hiányzik vagy hibás.",
|
||||||
|
"share_own": "Ez már a te letöltésed.",
|
||||||
|
"share_password_required": "Ehhez a linkhez jelszó kell.",
|
||||||
|
"share_unfinished": "Csak befejezett letöltés osztható meg.",
|
||||||
|
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat.",
|
||||||
|
"smtp_off": "Az SMTP nincs beállítva.",
|
||||||
|
"spec_not_object": "A profil beállításai hibás formátumúak.",
|
||||||
|
"still_downloading": "Ez a letöltés még folyamatban van.",
|
||||||
|
"tag_name_taken": "Már van „{{name}}” nevű címkéd.",
|
||||||
|
"title_required": "Adj meg egy címet.",
|
||||||
|
"too_many_attempts": "Túl sok próbálkozás. Próbáld kicsit később.",
|
||||||
|
"unknown_setting": "Ismeretlen beállítás: {{key}}",
|
||||||
|
"value_missing": "Adj meg egy értéket.",
|
||||||
|
"video_gone": "Ez a videó már nem érhető el.",
|
||||||
|
"view_name_taken": "Már van „{{name}}” nevű nézeted.",
|
||||||
|
"watch_sync_off": "Előbb kapcsold be a Plex megtekintés-szinkront.",
|
||||||
|
"weak_password": "A jelszónak legalább {{min}} karakterből kell állnia.",
|
||||||
|
"wrong_password": "A jelenlegi jelszó helytelen.",
|
||||||
|
"wrong_share_password": "Hibás jelszó.",
|
||||||
|
"youtube_editing_off": "Előbb kapcsold be a YouTube-szerkesztést a Beállításokban.",
|
||||||
|
"youtube_read_scope": "Előbb kapcsold össze a YouTube-fiókodat (olvasási hozzáférés)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
// One contract, one implementation — `notifyActionError` (see actionErrors.ts).
|
|
||||||
//
|
|
||||||
// `req` deliberately leaves 403/404/429 to the caller, and the routes behind those statuses
|
|
||||||
// hand-write English. Round 5 taught the shared helper to discard a plain-string `detail` in favour
|
|
||||||
// of the caller's already-translated fallback. Round 6 then found `ChatThread` carrying a SECOND,
|
|
||||||
// hand-written copy of the same contract (`e.detail || t("messages.sendFailed")`) — so the central
|
|
||||||
// fix could not reach it and the English sentence stayed in a Hungarian UI. A fix that lands in one
|
|
||||||
// place while a copy of the rule lives in another is this repo's recurring smell; this is the check
|
|
||||||
// that names the copies.
|
|
||||||
//
|
|
||||||
// The assertion pins the expected SET, never a count: a guard that counts is green the moment its
|
|
||||||
// glob stops matching (E10 review round 4 learned that the hard way).
|
|
||||||
|
|
||||||
// Reads a raw `HttpError.detail` and uses it as the message a user sees. Matches `e.detail ||`,
|
|
||||||
// `err.detail ? err.detail :`, `(e instanceof HttpError ? e.detail : undefined) ??`, a cast
|
|
||||||
// receiver (`(e as HttpError).detail`), an optional chain (`e?.detail`) and a `.detail` passed
|
|
||||||
// straight as a call argument (`setErr(e.detail)`).
|
|
||||||
//
|
|
||||||
// The receiver alternation and the absence of a `)` lookahead are both load-bearing. The first
|
|
||||||
// version required a bare identifier before `.detail` and excluded `.detail)` — so `setErr(e.detail)`,
|
|
||||||
// `String(e.detail)` and `(e as HttpError).detail` were all invisible, and because the file SET is
|
|
||||||
// compared with `toEqual` a file the pattern cannot see is simply never listed: the guard stayed
|
|
||||||
// green over a fresh copy of the contract written in any of those shapes (round 7). The `)`
|
|
||||||
// exclusion bought nothing — the only `.detail)` in the frontend is inside `lib/api/core.ts`, which
|
|
||||||
// `IMPLEMENTATION` skips wholesale — and re-scanning with the wider pattern returns the same five
|
|
||||||
// files, so it removes no false positive.
|
|
||||||
const RAW_DETAIL = /(?:\)|[A-Za-z_$][\w$]*)\s*\??\.detail\b(?!\s*=[^=])/;
|
|
||||||
// KNOWN LIMIT: a `detail` pulled out by destructuring (`const { detail } = err`) is out of reach
|
|
||||||
// of a line-level scan — by then there is no receiver left to match, and a pattern loose enough
|
|
||||||
// to catch it fires on every unrelated `detail` field too (measured: `const { data: detail } =
|
|
||||||
// useQuery(...)` in VideoInfoCard and an options destructure in plexPlayerHooks, neither of which
|
|
||||||
// touches an HttpError). Naming the hole beats pinning two innocent files into the list below as
|
|
||||||
// though they were copies of the contract.
|
|
||||||
|
|
||||||
// Every place that still prefers the server's raw string over a translated fallback. These are
|
|
||||||
// INLINE surfaces — a form field's error line, a setup step's banner — not toasts, and each shows a
|
|
||||||
// reason the user is mid-flow on, so converting them is a sweep of its own (with the ~60 backend
|
|
||||||
// routes that hand-write those strings). Logged on ROADMAP.md under the bug-hunt deferrals. What
|
|
||||||
// must not happen is a NEW one, or a toast path growing one again.
|
|
||||||
const KNOWN_RAW_DETAIL_CONSUMERS = [
|
|
||||||
"components/Feed.tsx", // the live-YouTube search error line
|
|
||||||
"components/KeyGate.tsx", // the passphrase field's own refusal
|
|
||||||
"components/SettingsPanel.tsx", // the change-password field
|
|
||||||
"components/SetupWizard.tsx", // the first-run wizard's banner
|
|
||||||
"components/Welcome.tsx", // the sign-in card's refusal panel
|
|
||||||
];
|
|
||||||
|
|
||||||
// Where the rule itself lives, plus the request layer that produces and localizes the value.
|
|
||||||
const IMPLEMENTATION = ["lib/actionErrors.ts", "lib/api/core.ts"];
|
|
||||||
|
|
||||||
function consumers(): { hits: string[]; scanned: number } {
|
|
||||||
const files = import.meta.glob("../**/*.{ts,tsx}", {
|
|
||||||
query: "?raw",
|
|
||||||
import: "default",
|
|
||||||
eager: true,
|
|
||||||
});
|
|
||||||
const hits: string[] = [];
|
|
||||||
for (const [rel, source] of Object.entries(files)) {
|
|
||||||
// Vite resolves the glob against THIS file, so same-directory hits come back as "./x".
|
|
||||||
const path = rel.startsWith("./") ? `lib/${rel.slice(2)}` : rel.replace(/^\.\.\//, "");
|
|
||||||
if (/\.test\.tsx?$|\.d\.ts$/.test(path)) continue;
|
|
||||||
if (IMPLEMENTATION.includes(path)) continue;
|
|
||||||
const lines = String(source)
|
|
||||||
.split("\n")
|
|
||||||
.filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)); // a comment about the rule is not a copy of it
|
|
||||||
if (lines.some((l) => RAW_DETAIL.test(l))) hits.push(path);
|
|
||||||
}
|
|
||||||
return { hits: hits.sort(), scanned: Object.keys(files).length };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("the refused-write contract", () => {
|
|
||||||
it("has no copies beyond the ones on the deferral list", () => {
|
|
||||||
const { hits, scanned } = consumers();
|
|
||||||
// The GLOB must have matched something, or this whole file is a green no-op — and the old
|
|
||||||
// version of this guard did not check that: it read `actionErrors.ts` by its own URL, which
|
|
||||||
// resolves whether or not `import.meta.glob` enumerated a single file. That only mattered
|
|
||||||
// because the pinned set was non-empty; the moment the deferral sweep below lands and the list
|
|
||||||
// becomes `[]`, `expect([]).toEqual([])` would pass over a glob that matched nothing (round 7).
|
|
||||||
expect(scanned).toBeGreaterThan(100);
|
|
||||||
expect(readFileSync(new URL("./actionErrors.ts", import.meta.url), "utf8")).toContain(
|
|
||||||
"detailData",
|
|
||||||
);
|
|
||||||
expect(hits).toEqual(KNOWN_RAW_DETAIL_CONSUMERS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sees a raw detail written in any of the shapes this codebase uses", () => {
|
|
||||||
// The pattern is the whole guard, so pin the pattern itself: each of these is a real form
|
|
||||||
// found in this frontend or in its sibling PRs, and every one of the first five was invisible
|
|
||||||
// to the original regex.
|
|
||||||
for (const line of [
|
|
||||||
" setErr(e.detail);",
|
|
||||||
" reportError(err.detail);",
|
|
||||||
" notify({ message: (e as HttpError).detail });",
|
|
||||||
" toast(String(e.detail));",
|
|
||||||
" notify({ message: (err as HttpError).detail ?? fallback });",
|
|
||||||
' setError(e.detail ?? t("x"));',
|
|
||||||
' const msg = err.detail || t("x");',
|
|
||||||
" setErr(state.err?.detail);",
|
|
||||||
])
|
|
||||||
expect(RAW_DETAIL.test(line), line).toBe(true);
|
|
||||||
// And it must not fire on the places that PRODUCE or localize the value, which is why
|
|
||||||
// `IMPLEMENTATION` exists rather than a cleverer pattern: an assignment is not a read.
|
|
||||||
for (const line of [" detail = await r.json();", " this.detail = detail;"])
|
|
||||||
expect(RAW_DETAIL.test(line), line).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,11 +9,13 @@ const GATEWAY = new Set([502, 503, 504]);
|
|||||||
/**
|
/**
|
||||||
* WHY a write failed, in the reader's language — the line under the toast's title.
|
* WHY a write failed, in the reader's language — the line under the toast's title.
|
||||||
*
|
*
|
||||||
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through
|
* The server's own reason wins: `req` has already run the structured detail through
|
||||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||||
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and
|
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
|
||||||
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that
|
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
|
||||||
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it.
|
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
|
||||||
|
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
|
||||||
|
* says it.
|
||||||
*/
|
*/
|
||||||
export function failureReason(err: unknown): string {
|
export function failureReason(err: unknown): string {
|
||||||
const t = i18n.t.bind(i18n);
|
const t = i18n.t.bind(i18n);
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ describe("uploadReq", () => {
|
|||||||
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
expect(reportError).toHaveBeenCalledWith("downloads.errors.upload.tooLarge");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers no string detail as the reason on the upload path either", async () => {
|
||||||
|
const p = uploadReq("/u", form());
|
||||||
|
const x = FakeXHR.last;
|
||||||
|
x.status = 405;
|
||||||
|
x.responseText = '{"detail":"Method Not Allowed"}';
|
||||||
|
x.onload!();
|
||||||
|
const err = (await p.catch((e: unknown) => e)) as HttpError;
|
||||||
|
expect(err).toBeInstanceOf(HttpError);
|
||||||
|
expect(err.detail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a connectivity failure on a network error", async () => {
|
it("reports a connectivity failure on a network error", async () => {
|
||||||
const p = uploadReq("/u", form());
|
const p = uploadReq("/u", form());
|
||||||
FakeXHR.last.onerror!();
|
FakeXHR.last.onerror!();
|
||||||
@@ -168,6 +179,23 @@ describe("req — a structured refusal reads in the user's language", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never offers a string detail as the reason — only a structured one, translated", async () => {
|
||||||
|
// Every refusal the app answers is structured; a string can only be the framework's own English
|
||||||
|
// ("Method Not Allowed"). Surfaces print `err.detail` — some destructured, out of any source
|
||||||
|
// scan's reach — so the guarantee has to live here, where the value is made.
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ detail: "Method Not Allowed" }), {
|
||||||
|
status: 405,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const err = (await req("/api/x", { method: "POST" }, { quiet: true }).catch(
|
||||||
|
(e: unknown) => e,
|
||||||
|
)) as HttpError;
|
||||||
|
expect(err).toBeInstanceOf(HttpError);
|
||||||
|
expect(err.detail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["quota", "reconnect", "unavailable"])(
|
it.each(["quota", "reconnect", "unavailable"])(
|
||||||
"says what to do about an action YouTube refused (%s)",
|
"says what to do about an action YouTube refused (%s)",
|
||||||
async (reason) => {
|
async (reason) => {
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ export function isAborted(err: unknown): boolean {
|
|||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
|
||||||
|
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
|
||||||
|
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
|
||||||
|
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
|
||||||
|
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
|
||||||
|
detail?: string;
|
||||||
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||||
constructor(status: number, detail?: string, detailData?: unknown) {
|
constructor(status: number, detail?: string, detailData?: unknown) {
|
||||||
super(detail || `HTTP ${status}`);
|
super(detail || `HTTP ${status}`);
|
||||||
@@ -133,6 +138,11 @@ function localizeDetail(d: unknown): string {
|
|||||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||||
if (o?.code === "youtube")
|
if (o?.code === "youtube")
|
||||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||||||
|
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||||||
|
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||||||
|
// reads as the generic failure rather than a key.
|
||||||
|
if (o?.code === "refused")
|
||||||
|
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||||||
// An admin action on an account or an invite that the server refused: activating an address that
|
// An admin action on an account or an invite that the server refused: activating an address that
|
||||||
@@ -326,8 +336,8 @@ export async function req<T = unknown>(
|
|||||||
let detailData: unknown;
|
let detailData: unknown;
|
||||||
try {
|
try {
|
||||||
const body = await r.json();
|
const body = await r.json();
|
||||||
if (body && typeof body.detail === "string") detail = body.detail;
|
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||||||
else if (body && body.detail && typeof body.detail === "object") {
|
if (body && body.detail && typeof body.detail === "object") {
|
||||||
detailData = body.detail;
|
detailData = body.detail;
|
||||||
detail = localizeDetail(body.detail); // structured error → localized message
|
detail = localizeDetail(body.detail); // structured error → localized message
|
||||||
}
|
}
|
||||||
@@ -393,8 +403,8 @@ export function uploadReq<T = unknown>(
|
|||||||
let detailData: unknown;
|
let detailData: unknown;
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
const body = JSON.parse(xhr.responseText);
|
||||||
if (body && typeof body.detail === "string") detail = body.detail;
|
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||||||
else if (body && body.detail && typeof body.detail === "object") {
|
if (body && body.detail && typeof body.detail === "object") {
|
||||||
detailData = body.detail;
|
detailData = body.detail;
|
||||||
detail = localizeDetail(body.detail);
|
detail = localizeDetail(body.detail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { ESLint } from "eslint";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// `local/jsx-comment-text` (eslint.config.js): a `//` inside JSX children is rendered as text. One
|
||||||
|
// reached the Playlists page past tsc, eslint and every test — only a visual check caught it. A rule
|
||||||
|
// that matches nothing looks exactly like a clean codebase, so this asserts it fires.
|
||||||
|
const RULE = "local/jsx-comment-text";
|
||||||
|
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
||||||
|
|
||||||
|
async function hits(source: string): Promise<number> {
|
||||||
|
const eslint = new ESLint({ cwd: FRONTEND });
|
||||||
|
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
|
||||||
|
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a comment inside JSX children", () => {
|
||||||
|
it("is refused — it would render as text", async () => {
|
||||||
|
expect(
|
||||||
|
await hits(`export const A = () => (\n <>\n // the list\n <ul />\n </>\n);\n`),
|
||||||
|
).toBe(1);
|
||||||
|
expect(await hits(`export const B = () => <div>/* note */</div>;\n`)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is fine as a real comment, and text that merely contains a slash pair is text", async () => {
|
||||||
|
expect(await hits(`export const C = () => <div>{/* the list */}</div>;\n`)).toBe(0);
|
||||||
|
expect(await hits(`export const D = () => <p>See https://example.com</p>;\n`)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user