refactor(api): Pydantic request models for plex routes + status unification (R6 S1b)

All 14 plex payload:dict endpoints typed (watch-link, collections, native
playlists, item/show/season state & progress). Shared PlexItemKeysIn (bulk add/
remove/reorder) and WatchedIn (show/season). item_progress's manual int()+400
becomes a 422 on a non-number.

Status-code unification (ROADMAP S1):
- the 5 collection write-failure 502s → 422, matching the house "external write
  failed" convention (playlists' YouTube 422). The image/subtitle proxy-fetch 502s
  stay (genuine gateway fetches).
- the field-validation 422s ("title is required", "title and item_rating_key are
  required") → 400, matching every other module's required-field 400.

Ends S1b: no `payload: dict` remains in app/routes. Gate: ruff clean, pytest 164.
This commit is contained in:
2026-07-26 05:08:27 +02:00
parent f4bf1bc18f
commit 66f4ac30d0
+107 -45
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_
from sqlalchemy.orm import Session, aliased
@@ -156,9 +157,13 @@ def watch_link_status(
return _link_dict(link)
class WatchLinkIn(BaseModel):
enabled: bool = False
@router.post("/watch/link")
def watch_link_set(
payload: dict,
body: WatchLinkIn,
user: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
@@ -167,7 +172,7 @@ def watch_link_set(
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
if not sysconfig.get_bool(db, "plex_enabled"):
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
enabled = bool(payload.get("enabled"))
enabled = body.enabled
link = db.query(PlexLink).filter_by(user_id=user.id).first()
if link is None:
link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc))
@@ -797,14 +802,22 @@ def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
return col
class CreateCollectionIn(BaseModel):
library: str = ""
title: str = ""
item_rating_key: str = ""
@router.post("/collections")
def create_collection(payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
def create_collection(
body: CreateCollectionIn, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Create a manual collection (seeded with one item — Plex needs at least one). Editable by default."""
library = str(payload.get("library") or "")
title = (payload.get("title") or "").strip()
seed = str(payload.get("item_rating_key") or "")
library = body.library
title = body.title.strip()
seed = body.item_rating_key
if not title or not seed:
raise HTTPException(status_code=422, detail="title and item_rating_key are required")
raise HTTPException(status_code=400, detail="title and item_rating_key are required")
lib = db.query(PlexLibrary).filter_by(plex_key=library).first()
if lib is None:
raise HTTPException(status_code=404, detail="Unknown library")
@@ -816,7 +829,7 @@ def create_collection(payload: dict, _: User = Depends(admin_user), db: Session
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return _collection_card(col)
@@ -833,7 +846,7 @@ def collection_add_item(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return _collection_card(col)
@@ -850,18 +863,22 @@ def collection_remove_item(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return _collection_card(col)
class CollectionRenameIn(BaseModel):
title: str = ""
@router.patch("/collections/{rating_key}")
def collection_rename(
rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)
rating_key: str, body: CollectionRenameIn, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
title = (payload.get("title") or "").strip()
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
raise HTTPException(status_code=400, detail="title is required")
lib = db.get(PlexLibrary, col.library_id)
try:
with PlexClient(db) as plex:
@@ -870,7 +887,7 @@ def collection_rename(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return _collection_card(col)
@@ -887,20 +904,24 @@ def collection_delete(
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return {"deleted": rating_key}
class CollectionEditableIn(BaseModel):
editable: bool = False
@router.post("/collections/{rating_key}/editable")
def collection_set_editable(
rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)
rating_key: str, body: CollectionEditableIn, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first()
if col is None:
raise HTTPException(status_code=404, detail="Unknown collection")
want = bool(payload.get("editable"))
want = body.editable
if want and (col.smart or _collection_source(col) != "collection"):
raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable")
col.editable = want
@@ -975,15 +996,22 @@ def playlists(
return resp
class CreatePlexPlaylistIn(BaseModel):
title: str = ""
item_rating_key: str = ""
@router.post("/playlists")
def create_playlist(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
title = (payload.get("title") or "").strip()
def create_playlist(
body: CreatePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
raise HTTPException(status_code=400, detail="title is required")
pl = PlexPlaylist(user_id=user.id, title=title)
db.add(pl)
db.flush()
seed = str(payload.get("item_rating_key") or "")
seed = body.item_rating_key
count = 0
thumb_rk = None
if seed:
@@ -1022,12 +1050,18 @@ def playlist_detail(pid: int, user: User = Depends(current_user), db: Session =
return {"id": pl.id, "title": pl.title, "items": cards}
class RenamePlexPlaylistIn(BaseModel):
title: str = ""
@router.patch("/playlists/{pid}")
def rename_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
def rename_playlist(
pid: int, body: RenamePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
title = (payload.get("title") or "").strip()
title = body.title.strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
raise HTTPException(status_code=400, detail="title is required")
pl.title = title
db.commit()
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0
@@ -1043,10 +1077,16 @@ def delete_playlist(pid: int, user: User = Depends(current_user), db: Session =
return {"deleted": pid}
class PlexItemRefIn(BaseModel):
item_rating_key: str = ""
@router.post("/playlists/{pid}/items")
def playlist_add_item(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
def playlist_add_item(
pid: int, body: PlexItemRefIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
it = db.query(PlexItem).filter_by(rating_key=str(payload.get("item_rating_key") or "")).first()
it = db.query(PlexItem).filter_by(rating_key=body.item_rating_key).first()
if it is None:
raise HTTPException(status_code=404, detail="Unknown item")
exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first()
@@ -1069,6 +1109,10 @@ def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(cu
return {"ok": True}
class PlexItemKeysIn(BaseModel):
item_rating_keys: list[str] = []
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
order = [str(k) for k in rks if str(k)]
@@ -1086,11 +1130,13 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
@router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
def playlist_add_bulk(
pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = _own_playlist_or_404(db, pid, user)
items = _items_by_rks(db, payload.get("item_rating_keys") or [])
items = _items_by_rks(db, body.item_rating_keys)
existing = {row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid)}
pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar())
added = 0
@@ -1109,11 +1155,13 @@ def playlist_add_bulk(pid: int, payload: dict, user: User = Depends(current_user
@router.post("/playlists/{pid}/items/remove-bulk")
def playlist_remove_bulk(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
def playlist_remove_bulk(
pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = _own_playlist_or_404(db, pid, user)
ids = [it.id for it in _items_by_rks(db, payload.get("item_rating_keys") or [])]
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
removed = 0
if ids:
removed = (
@@ -1129,10 +1177,12 @@ def playlist_remove_bulk(pid: int, payload: dict, user: User = Depends(current_u
@router.put("/playlists/{pid}/order")
def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
def reorder_playlist(
pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
pl = _own_playlist_or_404(db, pid, user)
order = [str(k) for k in (payload.get("item_rating_keys") or [])]
order = body.item_rating_keys
id_by_rk = {it.rating_key: it.id for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""]))}
for pos, rk in enumerate(order):
iid = id_by_rk.get(rk)
@@ -1892,10 +1942,16 @@ def _push_watch(
)
class ItemProgressIn(BaseModel):
position_seconds: int = 0
duration_seconds: int = 0
final: bool = False
@router.post("/item/{rating_key}/progress")
def item_progress(
rating_key: str,
payload: dict,
body: ItemProgressIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -1906,12 +1962,9 @@ def item_progress(
debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so
a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds."""
it = _item_or_404(db, rating_key)
try:
position = max(0, int(payload.get("position_seconds") or 0))
duration = int(payload.get("duration_seconds") or 0)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
final = bool(payload.get("final"))
position = max(0, body.position_seconds)
duration = body.duration_seconds
final = body.final
now = datetime.now(timezone.utc)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
was_watched = st is not None and st.status == "watched"
@@ -1957,10 +2010,14 @@ def item_progress(
return {"position_seconds": position}
class ItemStateIn(BaseModel):
status: str = ""
@router.post("/item/{rating_key}/state")
def item_state(
rating_key: str,
payload: dict,
body: ItemStateIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -1969,7 +2026,7 @@ def item_state(
to a linked Plex account immediately; `hidden` is a Siftlode-only concept with no Plex analogue,
so it never touches Plex."""
it = _item_or_404(db, rating_key)
status = payload.get("status")
status = body.status
if status not in ("new", "watched", "hidden"):
raise HTTPException(status_code=400, detail="Invalid status")
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
@@ -2035,10 +2092,15 @@ def _bulk_state(
return len(to_push)
# Shared by the show- and season-level "mark all episodes watched/unwatched" endpoints.
class WatchedIn(BaseModel):
watched: bool = False
@router.post("/show/{rating_key}/state")
def show_state(
rating_key: str,
payload: dict,
body: WatchedIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -2047,7 +2109,7 @@ def show_state(
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show")
watched = bool(payload.get("watched"))
watched = body.watched
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@@ -2056,7 +2118,7 @@ def show_state(
@router.post("/season/{rating_key}/state")
def season_state(
rating_key: str,
payload: dict,
body: WatchedIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -2065,7 +2127,7 @@ def season_state(
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season")
watched = bool(payload.get("watched"))
watched = body.watched
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}