"""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.`, 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.`). 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)})