Files
siftlode/backend/app/routes/_common.py
T
peter fad301e7a4 fix(youtube): a refused YouTube action answers a 422 that says what to do
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".

One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.

"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
2026-09-19 06:55:40 +02:00

64 lines
2.6 KiB
Python

"""Route-layer shared helpers for the API contract (R6 S1a).
Small, dependency-light guards that several route modules had hand-rolled with subtle
drift. Keeping them here (imported by the route modules) means the "fetch a row or 404"
and "fetch a row this user owns or 404" idioms have one definition, one status code, and
one existence-leak policy.
"""
from typing import Any, TypeVar
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base)
def not_found(what: str = "generic") -> HTTPException:
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
language (`errors.notFound.<what>`, generic fallback). This is the 404 a user actually meets: the
thing was deleted in another tab or on another device. It used to be a bare English string
("Unknown view", "Not found"), which the Hungarian UI toasted verbatim."""
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
if obj is None:
raise not_found(what)
return obj
def owned_or_404(
db: Session,
model: type[M],
id_: Any,
user: User,
*,
owner_attr: str = "user_id",
what: str = "generic",
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
Generalizes the near-identical per-route ownership helpers (e.g. plex's
`_own_playlist_or_404`, the saved-views owner check)."""
obj = get_or_404(db, model, id_, what=what)
if getattr(obj, owner_attr) != user.id:
raise not_found(what)
return obj
def youtube_refusal(exc: YouTubeError) -> HTTPException:
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
happened (`youtube.api.error`)."""
return HTTPException(status_code=422, detail={"code": "youtube", "reason": refusal_reason(exc)})