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 import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_ from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -156,9 +157,13 @@ def watch_link_status(
return _link_dict(link) return _link_dict(link)
class WatchLinkIn(BaseModel):
enabled: bool = False
@router.post("/watch/link") @router.post("/watch/link")
def watch_link_set( def watch_link_set(
payload: dict, body: WatchLinkIn,
user: User = Depends(admin_user), user: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> 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.""" "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 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() link = db.query(PlexLink).filter_by(user_id=user.id).first()
if link is None: if link is None:
link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)) 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 return col
class CreateCollectionIn(BaseModel):
library: str = ""
title: str = ""
item_rating_key: str = ""
@router.post("/collections") @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.""" """Create a manual collection (seeded with one item — Plex needs at least one). Editable by default."""
library = str(payload.get("library") or "") library = body.library
title = (payload.get("title") or "").strip() title = body.title.strip()
seed = str(payload.get("item_rating_key") or "") seed = body.item_rating_key
if not title or not seed: 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() lib = db.query(PlexLibrary).filter_by(plex_key=library).first()
if lib is None: if lib is None:
raise HTTPException(status_code=404, detail="Unknown library") 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() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() 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) return _collection_card(col)
@@ -833,7 +846,7 @@ def collection_add_item(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() 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) return _collection_card(col)
@@ -850,18 +863,22 @@ def collection_remove_item(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() 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) return _collection_card(col)
class CollectionRenameIn(BaseModel):
title: str = ""
@router.patch("/collections/{rating_key}") @router.patch("/collections/{rating_key}")
def collection_rename( 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: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
title = (payload.get("title") or "").strip() title = body.title.strip()
if not title: 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) lib = db.get(PlexLibrary, col.library_id)
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -870,7 +887,7 @@ def collection_rename(
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() 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) return _collection_card(col)
@@ -887,20 +904,24 @@ 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=502, detail=f"Plex write failed: {e}") raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
return {"deleted": rating_key} return {"deleted": rating_key}
class CollectionEditableIn(BaseModel):
editable: bool = False
@router.post("/collections/{rating_key}/editable") @router.post("/collections/{rating_key}/editable")
def collection_set_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: ) -> dict:
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections """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.""" (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first()
if col is None: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") 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"): if want and (col.smart or _collection_source(col) != "collection"):
raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable") raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable")
col.editable = want col.editable = want
@@ -975,15 +996,22 @@ def playlists(
return resp return resp
class CreatePlexPlaylistIn(BaseModel):
title: str = ""
item_rating_key: str = ""
@router.post("/playlists") @router.post("/playlists")
def create_playlist(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: def create_playlist(
title = (payload.get("title") or "").strip() body: CreatePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
title = body.title.strip()
if not title: 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) pl = PlexPlaylist(user_id=user.id, title=title)
db.add(pl) db.add(pl)
db.flush() db.flush()
seed = str(payload.get("item_rating_key") or "") seed = body.item_rating_key
count = 0 count = 0
thumb_rk = None thumb_rk = None
if seed: 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} return {"id": pl.id, "title": pl.title, "items": cards}
class RenamePlexPlaylistIn(BaseModel):
title: str = ""
@router.patch("/playlists/{pid}") @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) pl = _own_playlist_or_404(db, pid, user)
title = (payload.get("title") or "").strip() title = body.title.strip()
if not title: if not title:
raise HTTPException(status_code=422, detail="title is required") raise HTTPException(status_code=400, detail="title is required")
pl.title = title pl.title = title
db.commit() db.commit()
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 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} return {"deleted": pid}
class PlexItemRefIn(BaseModel):
item_rating_key: str = ""
@router.post("/playlists/{pid}/items") @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) 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: if it is None:
raise HTTPException(status_code=404, detail="Unknown item") raise HTTPException(status_code=404, detail="Unknown item")
exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() 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} return {"ok": True}
class PlexItemKeysIn(BaseModel):
item_rating_keys: list[str] = []
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys).""" """Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
order = [str(k) for k in rks if str(k)] 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") @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 """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.""" order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = _own_playlist_or_404(db, pid, user) 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)} 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()) pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar())
added = 0 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") @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 """Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total.""" removed + the new total."""
pl = _own_playlist_or_404(db, pid, user) 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 removed = 0
if ids: if ids:
removed = ( removed = (
@@ -1129,10 +1177,12 @@ def playlist_remove_bulk(pid: int, payload: dict, user: User = Depends(current_u
@router.put("/playlists/{pid}/order") @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).""" """Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
pl = _own_playlist_or_404(db, pid, user) 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 [""]))} 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): for pos, rk in enumerate(order):
iid = id_by_rk.get(rk) 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") @router.post("/item/{rating_key}/progress")
def item_progress( def item_progress(
rating_key: str, rating_key: str,
payload: dict, body: ItemProgressIn,
background: BackgroundTasks, background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), 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 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.""" a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds."""
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
try: position = max(0, body.position_seconds)
position = max(0, int(payload.get("position_seconds") or 0)) duration = body.duration_seconds
duration = int(payload.get("duration_seconds") or 0) final = body.final
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
final = bool(payload.get("final"))
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() 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" was_watched = st is not None and st.status == "watched"
@@ -1957,10 +2010,14 @@ def item_progress(
return {"position_seconds": position} return {"position_seconds": position}
class ItemStateIn(BaseModel):
status: str = ""
@router.post("/item/{rating_key}/state") @router.post("/item/{rating_key}/state")
def item_state( def item_state(
rating_key: str, rating_key: str,
payload: dict, body: ItemStateIn,
background: BackgroundTasks, background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), 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, to a linked Plex account immediately; `hidden` is a Siftlode-only concept with no Plex analogue,
so it never touches Plex.""" so it never touches Plex."""
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
status = payload.get("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 HTTPException(status_code=400, detail="Invalid status")
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() 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) 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") @router.post("/show/{rating_key}/state")
def show_state( def show_state(
rating_key: str, rating_key: str,
payload: dict, body: WatchedIn,
background: BackgroundTasks, background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -2047,7 +2109,7 @@ def show_state(
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
if sh is None: if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show") 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() eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched) changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched} return {"changed": changed, "watched": watched}
@@ -2056,7 +2118,7 @@ def show_state(
@router.post("/season/{rating_key}/state") @router.post("/season/{rating_key}/state")
def season_state( def season_state(
rating_key: str, rating_key: str,
payload: dict, body: WatchedIn,
background: BackgroundTasks, background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -2065,7 +2127,7 @@ def season_state(
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first() se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
if se is None: if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season") 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() eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched) changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched} return {"changed": changed, "watched": watched}