diff --git a/VERSION b/VERSION index 806cc5f..2b045fa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.54.0 \ No newline at end of file +0.55.0 \ No newline at end of file diff --git a/backend/alembic/versions/0060_plex_jsonb_null_normalize.py b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py new file mode 100644 index 0000000..b87e99e --- /dev/null +++ b/backend/alembic/versions/0060_plex_jsonb_null_normalize.py @@ -0,0 +1,34 @@ +"""normalize plex JSONB tag columns: scalar `null` -> SQL NULL (R6 S3a) + +Pre-0.53.1 rows persisted a genre-less show/movie's genres/directors/cast_names/collection_keys as +a JSONB scalar `null` (SQLAlchemy wrote a Python None as JSON null before none_as_null=True). The +0.53.1 hotfix added none_as_null=True (new writes are SQL NULL) + jsonb_typeof read-guards, but the +existing rows stayed JSON `null`, which kept those read-guards load-bearing rather than belt-only. + +This one-time data fixup rewrites those scalars to SQL NULL so the columns are uniform. Data-only, +forward-only: a SQL NULL is indistinguishable from the old JSON `null` to every reader (the guards +treat both as "not an array"), so there is nothing meaningful to downgrade. No schema change. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0060_plex_jsonb_null_normalize" +down_revision: Union[str, None] = "0059_dl_link_pw_version" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("plex_items", "plex_shows") +_COLS = ("genres", "directors", "cast_names", "collection_keys") + + +def upgrade() -> None: + for table in _TABLES: + for col in _COLS: + op.execute( + f"UPDATE {table} SET {col} = NULL WHERE jsonb_typeof({col}) = 'null'" + ) + + +def downgrade() -> None: + pass # forward-only: SQL NULL == the old JSON null for every reader diff --git a/backend/app/auth.py b/backend/app/auth.py index 024f62a..1723e25 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1010,6 +1010,19 @@ def require_human(user: User = Depends(current_user)) -> User: return user +def require_write_scope(user: User = Depends(require_human)) -> User: + """Dependency: require the YouTube write scope (subscribe/unsubscribe, playlist export/edit). + Single source of truth for the write gate — replaces the inline `if not has_write_scope(user)` + 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.""" + if not has_write_scope(user): + raise HTTPException( + status_code=403, + detail="Enable YouTube editing in Settings first.", + ) + return user + + 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 admin gate — every admin route/action depends on this rather than re-checking the role.""" diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 058349d..f1b9e82 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -129,7 +129,10 @@ def sync(db: Session) -> dict: else: _sync_shows(db, plex, lib, stats) _sync_collections(db, plex, lib, stats) - db.commit() + # Commit each section as it completes: a PlexError on a LATER section then keeps the + # libraries already mirrored (idempotent — the next run reconciles the rest), instead + # of the old single end-of-loop commit that a late failure rolled back entirely. + db.commit() # Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library # becomes filterable over successive runs (the cheap listing above only stored the top ~3). try: diff --git a/backend/app/quota.py b/backend/app/quota.py index 0e44839..9d31750 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -9,10 +9,12 @@ import contextvars from datetime import datetime, timezone from zoneinfo import ZoneInfo +from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app import sysconfig +from app.db import SessionLocal from app.models import ApiQuotaUsage, QuotaEvent _PACIFIC = ZoneInfo("America/Los_Angeles") @@ -99,8 +101,13 @@ def pacific_day_start_utc() -> datetime: def units_used_today(db: Session) -> int: - row = db.get(ApiQuotaUsage, pacific_today()) - return row.units_used if row else 0 + # A scalar SELECT (not db.get) so it bypasses the caller session's identity map — quota writes + # commit in a SEPARATE session (see record_usage), and a cached ORM row would otherwise hide + # their effect from a caller that already loaded today's row (measured()'s before/after diff). + # Under READ COMMITTED a fresh SELECT sees the other session's committed writes. + return db.scalar( + select(ApiQuotaUsage.units_used).where(ApiQuotaUsage.day == pacific_today()) + ) or 0 def remaining_today(db: Session) -> int: @@ -111,21 +118,24 @@ def can_spend(db: Session, units: int) -> bool: return remaining_today(db) >= units -def log_action(db: Session, user_id: int, action: str) -> None: +def log_action(user_id: int, action: str) -> None: """Record a zero-cost action event so per-user daily caps still count it. `record_usage` only logs when units are actually charged; a scrape-based search spends no - quota yet must still be rate-limited per user, so it logs its event here directly.""" - db.add(QuotaEvent(user_id=user_id, action=action, units=0)) - db.commit() + quota yet must still be rate-limited per user, so it logs its event here directly. + + Writes in its OWN session (like record_usage) — quota accounting is durable independent of the + caller's transaction and must never commit the caller's session (that would flush a job's + partial state mid-run, defeating its own rollback).""" + with SessionLocal() as qs: + qs.add(QuotaEvent(user_id=user_id, action=action, units=0)) + qs.commit() def actions_today(db: Session, user_id: int, action: str) -> int: """How many quota events of `action` this user has logged so far in the current Pacific day — for per-user, per-action daily caps (e.g. the live-search limit). Counts events, not units, so it only works for actions charged exactly once per user action.""" - from sqlalchemy import func, select # local import: keep the module's import head lean - return ( db.scalar( select(func.count()) @@ -140,8 +150,15 @@ def actions_today(db: Session, user_id: int, action: str) -> int: ) -def record_usage(db: Session, units: int) -> None: - """Atomically add `units` to today's counter (upsert) and log an attribution event.""" +def record_usage(units: int) -> None: + """Atomically add `units` to today's counter (upsert) and log an attribution event. + + Uses its OWN session, committed immediately, so the spend is DURABLE regardless of what the + calling job does next: the units were really consumed at YouTube, so a caller that later rolls + back (or is read-only and never commits) must NOT lose the record — under-counting the shared + daily budget risks overspending the real API quota. Equally, quota must never commit the + CALLER's session (the old behaviour) — that persisted a job's partial state mid-run behind its + own `except: db.rollback()`.""" if units <= 0: return day = pacific_today() @@ -153,7 +170,8 @@ def record_usage(db: Session, units: int) -> None: set_={"units_used": ApiQuotaUsage.units_used + units}, ) ) - db.execute(stmt) - # Audit detail: who/what spent it (per-user attribution; NULL actor = system). - db.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units)) - db.commit() + with SessionLocal() as qs: + qs.execute(stmt) + # Audit detail: who/what spent it (per-user attribution; NULL actor = system). + qs.add(QuotaEvent(user_id=_actor_id.get(), action=_action.get(), units=units)) + qs.commit() diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py new file mode 100644 index 0000000..9fa458a --- /dev/null +++ b/backend/app/routes/_common.py @@ -0,0 +1,44 @@ +"""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 + +M = TypeVar("M", bound=Base) + + +def get_or_404(db: Session, model: type[M], id_: Any, *, detail: str = "Not found") -> 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 HTTPException(status_code=404, detail=detail) + return obj + + +def owned_or_404( + db: Session, + model: type[M], + id_: Any, + user: User, + *, + owner_attr: str = "user_id", + detail: str = "Not found", +) -> 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_, detail=detail) + if getattr(obj, owner_attr) != user.id: + raise HTTPException(status_code=404, detail=detail) + return obj diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c507c6c..576bbc0 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -3,6 +3,7 @@ Also the demo-account admin surface: the demo email whitelist + a manual state r from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -27,7 +28,7 @@ from app.models import ( Video, VideoState, ) -from app.utils import valid_email +from app.utils import iso, valid_email router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -38,8 +39,8 @@ def _serialize(inv: Invite) -> dict: "email": inv.email, "status": inv.status, "note": inv.note, - "requested_at": inv.requested_at.isoformat() if inv.requested_at else None, - "decided_at": inv.decided_at.isoformat() if inv.decided_at else None, + "requested_at": iso(inv.requested_at), + "decided_at": iso(inv.decided_at), "decided_by": inv.decided_by, } @@ -110,14 +111,18 @@ def deny_invite( return _serialize(_decide(db, invite_id, admin, approved=False)) +class AddInviteIn(BaseModel): + email: str = "" + + @router.post("/invites") def add_invite( - payload: dict, + body: AddInviteIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Manually whitelist an email (approved straight away). Convenience for the admin.""" - email = (payload.get("email") or "").strip().lower() + email = body.email.strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() @@ -147,7 +152,7 @@ def _serialize_user(u: User) -> dict: "is_demo": u.is_demo, "has_password": u.password_hash is not None, "has_google": u.google_sub is not None, - "created_at": u.created_at.isoformat() if u.created_at else None, + "created_at": iso(u.created_at), } @@ -157,17 +162,21 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> return [_serialize_user(u) for u in rows] +class SetRoleIn(BaseModel): + role: str + + @router.patch("/users/{user_id}/role") def set_user_role( user_id: int, - payload: dict, + body: SetRoleIn, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Promote/demote a user. Guards: can't change your own role, can't touch the demo account, and can't remove the last remaining admin. Emails the user when it actually changes.""" - role = payload.get("role") + role = body.role if role not in ("user", "admin"): raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") target = db.get(User, user_id) @@ -198,10 +207,14 @@ def set_user_role( return _serialize_user(target) +class SetSuspendedIn(BaseModel): + suspended: bool = False + + @router.patch("/users/{user_id}/suspend") def set_user_suspended( user_id: int, - payload: dict, + body: SetSuspendedIn, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), @@ -211,7 +224,7 @@ def set_user_suspended( suspend yourself, and can't suspend the last active admin (that would lock admin out). On un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked sign-in).""" - suspended = bool(payload.get("suspended")) + suspended = body.suspended target = db.get(User, user_id) if target is None: raise HTTPException(status_code=404, detail="No such user") @@ -280,7 +293,7 @@ def _serialize_demo(row: DemoWhitelist) -> dict: "email": row.email, "note": row.note, "added_by": row.added_by, - "created_at": row.created_at.isoformat() if row.created_at else None, + "created_at": iso(row.created_at), } @@ -296,15 +309,20 @@ def list_demo_whitelist( return [_serialize_demo(r) for r in rows] +class AddDemoWhitelistIn(BaseModel): + email: str = "" + note: str | None = None + + @router.post("/demo/whitelist") def add_demo_whitelist( - payload: dict, + body: AddDemoWhitelistIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Add an email that may enter the shared demo account from the login page (no Google sign-in). Idempotent — re-adding an existing email just returns it.""" - email = (payload.get("email") or "").strip().lower() + email = body.email.strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") row = db.execute( @@ -313,7 +331,7 @@ def add_demo_whitelist( if row is None: row = DemoWhitelist( email=email, - note=(payload.get("note") or "").strip() or None, + note=(body.note or "").strip() or None, added_by=admin.email, ) db.add(row) diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py index 206a32c..9e7a4ef 100644 --- a/backend/app/routes/audit.py +++ b/backend/app/routes/audit.py @@ -12,6 +12,7 @@ from app.audit import AuditAction from app.db import get_db from app.models import AuditLog, User from app.routes.admin import admin_user +from app.utils import iso router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) @@ -23,7 +24,7 @@ MAX_ROWS = 2000 def _serialize(row: AuditLog, actors: dict[int, str]) -> dict: return { "id": row.id, - "created_at": row.created_at.isoformat() if row.created_at else None, + "created_at": iso(row.created_at), # actor_id NULL = the scheduler/background did it (client shows "System"); a set id that # can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present. "actor": actors.get(row.actor_id) if row.actor_id is not None else None, diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 6b3c4da..dd04589 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -5,11 +5,12 @@ import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import func, select, update from sqlalchemy.orm import Session from app import quota, sysconfig -from app.auth import current_user, has_write_scope, require_human +from app.auth import current_user, require_human, require_write_scope from app.db import get_db from app.models import ( LIVE_OR_UPCOMING, @@ -24,10 +25,12 @@ from app.models import ( User, Video, ) +from app.routes._common import owned_or_404 from app.routes.admin import admin_user from app.sync.explore import explore_ingest_page from app.sync.runner import run_recent_backfill from app.sync.subscriptions import apply_channel_details +from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/channels", tags=["channels"]) @@ -67,7 +70,7 @@ def list_channels( ).all(): agg[cid] = { "stored": n, - "last_video_at": last_up.isoformat() if last_up else None, + "last_video_at": iso(last_up), "total_duration_seconds": int(total_dur or 0), "count_short": int(shorts), "count_live": int(live), @@ -232,7 +235,7 @@ def _channel_detail_dict( "subscriber_count": channel.subscriber_count, "video_count": channel.video_count, "total_view_count": channel.total_view_count, - "published_at": channel.published_at.isoformat() if channel.published_at else None, + "published_at": iso(channel.published_at), "external_links": channel.external_links or [], "country": channel.country, "default_language": channel.default_language, @@ -377,28 +380,37 @@ def _is_blocked(db: Session, user: User, channel_id: str) -> bool: ) +class ChannelUpdateIn(BaseModel): + # PATCH semantics: a field left unset (None) is untouched. Every field is optional so the + # UI can send just the one control it changed. int/bool coercion + rejection of garbage + # (a non-numeric priority, a non-bool flag) is now Pydantic's job → 422, not a 500. + priority: int | None = None + hidden: bool | None = None + deep_requested: bool | None = None + + @router.patch("/{channel_id}") def update_channel( channel_id: str, - payload: dict, + body: ChannelUpdateIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: sub = _user_subscription(db, user, channel_id) channel = db.get(Channel, channel_id) deep_turned_on = False - if "priority" in payload: - sub.priority = int(payload["priority"]) - if "hidden" in payload: - sub.hidden = bool(payload["hidden"]) + if body.priority is not None: + sub.priority = body.priority + if body.hidden is not None: + sub.hidden = body.hidden # The demo account is SHARED, so it must not persist deep_requested (a quota-relevant flag) onto # the shared subscription — skip the write entirely for demo, which also keeps deep_turned_on # False so the immediate backfill below can never fire for it. - if "deep_requested" in payload and not user.is_demo: + if body.deep_requested is not None and not user.is_demo: # Opt this channel into (or out of) full-history backfill. The deep scheduler # picks the flag up on its next run; turning it off won't undo already-fetched # videos, it just stops further deep paging if nobody else still wants it. - want = bool(payload["deep_requested"]) + want = body.deep_requested deep_turned_on = want and not sub.deep_requested sub.deep_requested = want db.commit() @@ -454,19 +466,14 @@ def reset_backfill( @router.post("/{channel_id}/subscribe") def subscribe( channel_id: str, - user: User = Depends(current_user), + user: User = Depends(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Subscribe to a channel discovered from the user's playlists. Gated behind the - optional write scope (YouTube subscriptions.insert). Creates the local subscription - with YouTube's returned resource id and enriches the channel's metadata if it's still - a stub, so the new row renders properly in the manager. It does NOT pull the channel's - videos — the scheduler picks those up on its next run, like any subscribed channel.""" - if not has_write_scope(user): - raise HTTPException( - status_code=403, - detail="Enable playlist editing in Settings to subscribe on YouTube.", - ) + optional write scope (YouTube subscriptions.insert) via require_write_scope. Creates the + local subscription with YouTube's returned resource id and enriches the channel's metadata + if it's still a stub, so the new row renders properly in the manager. It does NOT pull the + channel's videos — the scheduler picks those up on its next run, like any subscribed channel.""" channel = db.get(Channel, channel_id) if channel is None: raise HTTPException(status_code=404, detail="Unknown channel") @@ -524,16 +531,12 @@ def subscribe( @router.delete("/{channel_id}/subscription") def unsubscribe( channel_id: str, - user: User = Depends(current_user), + user: User = Depends(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Unsubscribe from this channel on YouTube and drop the local subscription. Gated - behind the optional write scope; read-only users get a 403 and should Hide instead.""" - if not has_write_scope(user): - raise HTTPException( - status_code=403, - detail="Enable playlist editing in Settings to unsubscribe on YouTube.", - ) + behind the optional write scope (require_write_scope); read-only users get a 403 and + should Hide instead.""" sub = _user_subscription(db, user, channel_id) if not sub.yt_subscription_id: raise HTTPException( @@ -609,23 +612,25 @@ def unblock_channel( return {"unblocked": channel_id} +class AttachTagIn(BaseModel): + tag_id: int + + @router.post("/{channel_id}/tags") def attach_tag( channel_id: str, - payload: dict, + body: AttachTagIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: _user_subscription(db, user, channel_id) - tag_id = payload.get("tag_id") - tag = db.get(Tag, tag_id) if tag_id is not None else None - # Only the user's own tags may be attached as personal links. - if tag is None or tag.user_id != user.id: - raise HTTPException(status_code=404, detail="Unknown tag") + # Only the user's own tags may be attached as personal links (owned_or_404 → 404 if the tag + # is missing OR belongs to someone else, without leaking which). + owned_or_404(db, Tag, body.tag_id, user, detail="Unknown tag") exists = db.execute( select(ChannelTag).where( ChannelTag.channel_id == channel_id, - ChannelTag.tag_id == tag_id, + ChannelTag.tag_id == body.tag_id, ChannelTag.user_id == user.id, ) ).scalar_one_or_none() @@ -633,13 +638,13 @@ def attach_tag( db.add( ChannelTag( channel_id=channel_id, - tag_id=tag_id, + tag_id=body.tag_id, user_id=user.id, source="user", ) ) db.commit() - return {"channel_id": channel_id, "tag_id": tag_id} + return {"channel_id": channel_id, "tag_id": body.tag_id} @router.delete("/{channel_id}/tags/{tag_id}") diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 9e90a10..9513162 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -1,8 +1,10 @@ """Admin Configuration page API: read/edit the DB-overridable operational settings defined in app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned.""" import re +from typing import Any from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy.orm import Session from app import audit @@ -30,17 +32,24 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> return sysconfig.describe(db) +class ConfigSetIn(BaseModel): + # A config value is arbitrarily typed (str/int/bool/…), validated downstream by + # sysconfig.set_value. `value` defaults to a sentinel so a MISSING value stays a 400 + # ("Missing 'value'") while an explicit `null` is a real, settable value. + value: Any = None + + @router.patch("/{key}") def set_config( key: str, - payload: dict, + body: ConfigSetIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: s = sysconfig.spec(key) if s is None: raise HTTPException(status_code=404, detail="Unknown config key") - if "value" not in payload: + if "value" not in body.model_fields_set: raise HTTPException(status_code=400, detail="Missing 'value'") if s.secret and not sysconfig.secrets_manageable(): raise HTTPException( @@ -49,7 +58,7 @@ def set_config( ) old = None if s.secret else sysconfig.get(db, key) try: - sysconfig.set_value(db, key, payload["value"]) + sysconfig.set_value(db, key, body.value) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index f3b4943..2c5fefd 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -11,6 +11,7 @@ from urllib.parse import parse_qs, urlparse from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse +from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -32,6 +33,7 @@ from app.models import ( ) from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses +from app.utils import iso router = APIRouter(prefix="/api/downloads", tags=["downloads"]) admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]) @@ -80,7 +82,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "phase": job.phase, "error": job.error, "queue_pos": job.queue_pos, - "created_at": job.created_at.isoformat() if job.created_at else None, + "created_at": iso(job.created_at), "source_kind": job.source_kind, "source_ref": job.source_ref, "source_url": service.reference_url(job, asset), @@ -106,7 +108,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "status": asset.status, "title": asset.title, "uploader": asset.uploader, - "upload_date": asset.upload_date.isoformat() if asset.upload_date else None, + "upload_date": iso(asset.upload_date), "duration_s": asset.duration_s, "size_bytes": asset.size_bytes, "width": asset.width, @@ -114,7 +116,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "container": asset.container, "uploader_url": asset.uploader_url, "thumbnail_url": asset.thumbnail_url, - "expires_at": asset.expires_at.isoformat() if asset.expires_at else None, + "expires_at": iso(asset.expires_at), }, } @@ -172,16 +174,19 @@ def list_profiles( return [_serialize_profile(p) for p in rows] +class CreateProfileIn(BaseModel): + name: str + spec: dict + + @router.post("/profiles") def create_profile( - payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db) + body: CreateProfileIn, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: - name = (payload.get("name") or "").strip() - spec = payload.get("spec") + name = body.name.strip() + spec = body.spec if not name: raise HTTPException(status_code=400, detail="A profile name is required.") - if not isinstance(spec, dict): - raise HTTPException(status_code=400, detail="spec must be an object") maxpos = db.execute( select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where( DownloadProfile.user_id == user.id @@ -195,25 +200,31 @@ def create_profile( return _serialize_profile(p) +class UpdateProfileIn(BaseModel): + name: str | None = None + spec: dict | None = None + + @router.patch("/profiles/{profile_id}") def update_profile( profile_id: int, - payload: dict, + body: UpdateProfileIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: p = db.get(DownloadProfile, profile_id) if p is None or p.is_builtin or p.user_id != user.id: raise HTTPException(status_code=404, detail="Unknown profile") - if "name" in payload: - name = (payload.get("name") or "").strip() + sent = body.model_fields_set + if "name" in sent: + name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="A profile name is required.") p.name = name[:80] - if "spec" in payload: - if not isinstance(payload["spec"], dict): + if "spec" in sent: + if body.spec is None: raise HTTPException(status_code=400, detail="spec must be an object") - p.spec = payload["spec"] + p.spec = body.spec db.commit() return _serialize_profile(p) @@ -230,17 +241,18 @@ def delete_profile( return {"deleted": profile_id} -def _resolve_spec(db: Session, user: User, payload: dict) -> tuple[dict, int | None]: +def _resolve_spec( + db: Session, user: User, profile_id: int | None, spec: dict | None +) -> tuple[dict, int | None]: """Pick the format spec for an enqueue: an explicit profile_id (builtin or the user's own), an inline spec, or the first builtin as a fallback.""" - pid = payload.get("profile_id") - if pid is not None: - p = db.get(DownloadProfile, pid) + if profile_id is not None: + p = db.get(DownloadProfile, profile_id) if p is None or (not p.is_builtin and p.user_id != user.id): raise HTTPException(status_code=404, detail="Unknown profile") return p.spec, p.id - if isinstance(payload.get("spec"), dict): - return payload["spec"], None + if spec is not None: + return spec, None fallback = db.execute( select(DownloadProfile) .where(DownloadProfile.is_builtin.is_(True)) @@ -254,13 +266,20 @@ def _resolve_spec(db: Session, user: User, payload: dict) -> tuple[dict, int | N # --- enqueue + list + manage --------------------------------------------------------------- +class EnqueueDownloadIn(BaseModel): + source: str = "" + profile_id: int | None = None + spec: dict | None = None + display_name: str | None = None + + @router.post("") def enqueue_download( - payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db) + body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: - source_kind, source_ref = resolve_source(payload.get("source") or "") - spec, profile_id = _resolve_spec(db, user, payload) - display_name = (payload.get("display_name") or "").strip() or None + source_kind, source_ref = resolve_source(body.source) + spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec) + display_name = (body.display_name or "").strip() or None try: job = service.enqueue( db, user.id, source_kind, source_ref, spec, profile_id, display_name @@ -270,22 +289,26 @@ def enqueue_download( return _serialize_job(db, job) +class EnqueueEditIn(BaseModel): + source_job_id: int = 0 + edit_spec: dict + display_name: str | None = None + + @router.post("/edit") def enqueue_edit( - payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db) + body: EnqueueEditIn, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: """Queue a phase-2 editor derivative (trim/crop) of a download this user can access. The source may be the user's own download OR one shared with them — either way the resulting clip is a NEW per-user derivative in the editor's own library (counts against their quota); the source file is only read, never modified, so editing a shared video is safe.""" - source_job = _accessible_job(db, user, int(payload.get("source_job_id") or 0)) + source_job = _accessible_job(db, user, body.source_job_id) 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.") - spec = payload.get("edit_spec") - if not isinstance(spec, dict): - raise HTTPException(status_code=400, detail="edit_spec must be an object") - display_name = (payload.get("display_name") or "").strip() or None + spec = body.edit_spec + display_name = (body.display_name or "").strip() or None try: job = service.enqueue_edit(db, user.id, source_job, spec, display_name) except quota.QuotaExceeded as e: @@ -388,26 +411,34 @@ def _clean_url(raw) -> str | None: return url[:2048] +class UpdateDownloadMetaIn(BaseModel): + # PATCH display-only overrides; presence (model_fields_set) decides what's touched. A field + # set to null/"" clears the override (falls back to the on-disk system value). + display_name: str | None = None + display_uploader: str | None = None + display_uploader_url: str | None = None + extra_links: list | None = None + + @router.patch("/{job_id}") def update_download_meta( job_id: int, - payload: dict, + body: UpdateDownloadMetaIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: """Update a download's display metadata: title, channel name + link, and extra reference URLs. All are display-only overrides — the on-disk file keeps its system-decided, id-bound name.""" job = _own_job(db, user, job_id) - if "display_name" in payload: - job.display_name = ((payload.get("display_name") or "").strip())[:255] or None - if "display_uploader" in payload: - job.display_uploader = ((payload.get("display_uploader") or "").strip())[:255] or None - if "display_uploader_url" in payload: - job.display_uploader_url = _clean_url(payload.get("display_uploader_url")) - if "extra_links" in payload: - raw = payload.get("extra_links") or [] - if not isinstance(raw, list): - raise HTTPException(status_code=400, detail="extra_links must be a list") + sent = body.model_fields_set + if "display_name" in sent: + job.display_name = ((body.display_name or "").strip())[:255] or None + if "display_uploader" in sent: + job.display_uploader = ((body.display_uploader or "").strip())[:255] or None + if "display_uploader_url" in sent: + job.display_uploader_url = _clean_url(body.display_uploader_url) + if "extra_links" in sent: + raw = body.extra_links or [] cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS] job.extra_links = cleaned or None db.commit() @@ -651,17 +682,21 @@ def storyboard_image( # --- sharing ------------------------------------------------------------------------------- +class ShareDownloadIn(BaseModel): + email: str = "" + + @router.post("/{job_id}/share") def share_download( job_id: int, - payload: dict, + body: ShareDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: job = _own_job(db, user, job_id) if job.status != "done" or job.asset_id is None: raise HTTPException(status_code=409, detail="Only a finished download can be shared.") - target = (payload.get("email") or "").strip().lower() + target = body.email.strip().lower() if not target: raise HTTPException(status_code=400, detail="A recipient email is required.") recipient = db.execute( @@ -770,17 +805,12 @@ def _own_link(db: Session, user: User, link_id: int) -> DownloadLink: return link -def _link_expiry(payload: dict) -> datetime | None: - days = payload.get("expires_days") - if days in (None, "", 0, "0"): +def _link_expiry(days: int | None) -> datetime | None: + # None/0/negative → no expiry; otherwise N days out, capped at ~10 years. The Pydantic model + # already coerced/validated the type, so a non-number is a 422 before we get here. + if not days or days <= 0: return None - try: - d = int(days) - except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="expires_days must be a number") - if d <= 0: - return None - return datetime.now(timezone.utc) + timedelta(days=min(d, 3650)) + return datetime.now(timezone.utc) + timedelta(days=min(days, 3650)) @router.get("/{job_id}/links") @@ -798,24 +828,30 @@ def list_links( return [linksmod.owner_view(row) for row in rows] +class CreateLinkIn(BaseModel): + password: str = "" + allow_download: bool = False + expires_days: int | None = None + + @router.post("/{job_id}/links") def create_link( job_id: int, - payload: dict, + body: CreateLinkIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: job = _own_job(db, user, job_id) if job.status != "done" or job.asset_id is None: raise HTTPException(status_code=409, detail="Only a finished download can be shared.") - pw = (payload.get("password") or "").strip() + pw = body.password.strip() link = DownloadLink( job_id=job_id, token=linksmod.new_token(), created_by=user.id, - allow_download=bool(payload.get("allow_download")), + allow_download=body.allow_download, password_hash=hash_password(pw) if pw else None, - expires_at=_link_expiry(payload), + expires_at=_link_expiry(body.expires_days), ) db.add(link) db.commit() @@ -823,20 +859,27 @@ def create_link( return linksmod.owner_view(link) +class UpdateLinkIn(BaseModel): + allow_download: bool | None = None + expires_days: int | None = None + password: str | None = None + + @router.patch("/links/{link_id}") def update_link( link_id: int, - payload: dict, + body: UpdateLinkIn, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> dict: link = _own_link(db, user, link_id) - if "allow_download" in payload: - link.allow_download = bool(payload["allow_download"]) - if "expires_days" in payload: - link.expires_at = _link_expiry(payload) - if "password" in payload: - pw = (payload.get("password") or "").strip() + sent = body.model_fields_set + if "allow_download" in sent: + link.allow_download = bool(body.allow_download) + if "expires_days" in sent: + link.expires_at = _link_expiry(body.expires_days) + if "password" in sent: + pw = (body.password or "").strip() link.password_hash = hash_password(pw) if pw else None # Bump the version so any grant issued under the previous password stops working now, rather # than living out its 6h TTL (setting/clearing/changing the password all count as a rotation). @@ -932,10 +975,19 @@ def admin_get_quota( } +class AdminSetQuotaIn(BaseModel): + # Each field is set only when provided as a real value (is not None). A non-numeric limit + # used to reach int() and 500; it's now a clean 422. + max_bytes: int | None = None + max_concurrent: int | None = None + max_jobs: int | None = None + unlimited: bool | None = None + + @admin_router.put("/quota/{user_id}") def admin_set_quota( user_id: int, - payload: dict, + body: AdminSetQuotaIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: @@ -952,14 +1004,14 @@ def admin_set_quota( unlimited=cur.unlimited, ) db.add(row) - if "max_bytes" in payload: - row.max_bytes = max(0, int(payload["max_bytes"])) - if "max_concurrent" in payload: - row.max_concurrent = max(1, int(payload["max_concurrent"])) - if "max_jobs" in payload: - row.max_jobs = max(1, int(payload["max_jobs"])) - if "unlimited" in payload: - row.unlimited = bool(payload["unlimited"]) + if body.max_bytes is not None: + row.max_bytes = max(0, body.max_bytes) + if body.max_concurrent is not None: + row.max_concurrent = max(1, body.max_concurrent) + if body.max_jobs is not None: + row.max_jobs = max(1, body.max_jobs) + if body.unlimited is not None: + row.unlimited = body.unlimited db.commit() return admin_get_quota(user_id, admin, db) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 443ee19..89d0367 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -5,6 +5,7 @@ import re from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, aliased @@ -29,6 +30,7 @@ from app.models import ( VideoState, ) from app.sync.videos import parse_iso8601_duration +from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) @@ -97,7 +99,7 @@ def _serialize(row) -> dict: "channel_title": row.channel_title, "channel_thumbnail": row.channel_thumbnail, "channel_url": _channel_url(row.channel_id, row.channel_handle), - "published_at": row.published_at.isoformat() if row.published_at else None, + "published_at": iso(row.published_at), "thumbnail_url": row.thumbnail_url, "duration_seconds": row.duration_seconds, "view_count": row.view_count, @@ -570,14 +572,18 @@ def get_facets( return {"counts": counts} +class VideoStateIn(BaseModel): + status: str + + @router.post("/videos/{video_id}/state") def set_video_state( video_id: str, - payload: dict, + body: VideoStateIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - status = payload.get("status") + status = body.status if status not in VALID_STATES: raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") if db.get(Video, video_id) is None: @@ -652,10 +658,18 @@ def clear_video_state( return {"video_id": video_id, "status": "new", "position_seconds": 0} +class VideoProgressIn(BaseModel): + # Accept a float and truncate (below) — the player may post a raw currentTime (a float), which + # the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the + # 10s progress hot path, losing the resume position; `float` keeps the old tolerance. + position_seconds: float = 0 + duration_seconds: float = 0 + + @router.post("/videos/{video_id}/progress") def set_video_progress( video_id: str, - payload: dict, + body: VideoProgressIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -663,11 +677,8 @@ def set_video_progress( Stores a per-user position on the video_states row without touching watch status, so a partially-watched video can render a progress bar and match the "in progress" filter. Trivially-early and near-finished positions clear the position rather than store it.""" - try: - position = 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") + position = int(body.position_seconds) + duration = int(body.duration_seconds) if position < 0: position = 0 @@ -732,7 +743,7 @@ def get_video_detail( "in_db": True, "channel_id": v.channel_id, "channel_title": v.channel.title if v.channel else None, - "published_at": v.published_at.isoformat() if v.published_at else None, + "published_at": iso(v.published_at), "view_count": v.view_count, "duration_seconds": v.duration_seconds, } diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index d21e9db..5d683d5 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -45,9 +46,13 @@ def my_accounts( return out +class SwitchAccountIn(BaseModel): + user_id: int + + @router.post("/switch") def switch_account( - payload: dict, + body: SwitchAccountIn, request: Request, user: User = Depends(current_user), db: Session = Depends(get_db), @@ -55,7 +60,7 @@ def switch_account( """Switch the active account to another one already authenticated in this browser. No Google round-trip — but only accounts present in the session list (proof they signed in here) are allowed, and access is re-checked in case the invite was revoked since.""" - target = payload.get("user_id") + target = body.user_id if target not in (request.session.get("account_ids") or []): raise HTTPException(status_code=403, detail="Not an account you've signed into here.") u = db.get(User, target) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 7ac772e..b953690 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -27,6 +27,7 @@ from app.ratelimit import RateLimiter from app.realtime import manager from app.security import verify_password from app.userscope import is_messageable_user, messageable_clauses +from app.utils import iso router = APIRouter(prefix="/api/messages", tags=["messages"]) @@ -97,8 +98,8 @@ def _serialize_msg(m: Message) -> dict: "body": m.body, # only set for system messages "ciphertext": m.ciphertext, "iv": m.iv, - "read_at": m.read_at.isoformat() if m.read_at else None, - "created_at": m.created_at.isoformat() if m.created_at else None, + "read_at": iso(m.read_at), + "created_at": iso(m.created_at), } diff --git a/backend/app/routes/notifications.py b/backend/app/routes/notifications.py index 224a7f7..132e478 100644 --- a/backend/app/routes/notifications.py +++ b/backend/app/routes/notifications.py @@ -12,6 +12,7 @@ from app.auth import current_user from app.db import get_db from app.models import Notification, User from app.notifications import trim_read +from app.utils import iso router = APIRouter(prefix="/api/me/notifications", tags=["notifications"]) @@ -25,7 +26,7 @@ def _serialize(n: Notification) -> dict: "data": n.data, "read": n.read, "dismissed": n.dismissed, - "created_at": n.created_at.isoformat() if n.created_at else None, + "created_at": iso(n.created_at), } diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index e37d625..d61a107 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -2,11 +2,12 @@ catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases.""" from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import func, select, and_ from sqlalchemy.orm import Session, aliased from app import quota -from app.auth import current_user, has_read_scope, has_write_scope +from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.routes.feed import _serialize, feed_columns @@ -197,13 +198,22 @@ def list_playlists( ] +class CreatePlaylistIn(BaseModel): + name: str + + +# Shared by the two "add a video" endpoints (Watch-later shortcut + explicit add-to-playlist). +class VideoRefIn(BaseModel): + video_id: str + + @router.post("") def create_playlist( - payload: dict, + body: CreatePlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - name = (payload.get("name") or "").strip() + name = body.name.strip() if not name: raise HTTPException(status_code=400, detail="name is required") pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id)) @@ -233,13 +243,13 @@ def _watch_later(db: Session, user: User) -> Playlist: @router.post("/watch-later") def add_watch_later( - payload: dict, + body: VideoRefIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Bookmark shortcut: add a video to the built-in Watch later playlist.""" - video_id = payload.get("video_id") - if not video_id or db.get(Video, video_id) is None: + video_id = body.video_id + if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") pl = _watch_later(db, user) _add_item(db, pl, video_id, mark_dirty=False) @@ -316,16 +326,13 @@ def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: @router.get("/{playlist_id}/push-plan") def push_plan( playlist_id: int, - user: User = Depends(current_user), + user: User = Depends(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a - confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).""" + confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page). + Write scope is required (require_write_scope) — syncing is a YouTube write.""" pl = _pushable(db, user, playlist_id) - if not has_write_scope(user): - raise HTTPException( - status_code=403, detail="Enable YouTube editing in Settings first." - ) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): plan = plan_push(db, user, pl) @@ -339,17 +346,13 @@ def push_plan( @router.post("/{playlist_id}/push") def push( playlist_id: int, - user: User = Depends(current_user), + user: User = Depends(require_write_scope), db: Session = Depends(get_db), ) -> dict: """Push a local playlist to YouTube (create it on first push, else reconcile to match local — local wins). Refuses if the estimated quota exceeds what's left today, so a - big playlist can't strand half-pushed.""" + big playlist can't strand half-pushed. Write scope required (require_write_scope).""" pl = _pushable(db, user, playlist_id) - if not has_write_scope(user): - raise HTTPException( - status_code=403, detail="Enable YouTube editing in Settings first." - ) try: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): # Read the live playlist ONCE (linked playlists only) and share it with both the @@ -401,16 +404,20 @@ def get_playlist( } +class RenamePlaylistIn(BaseModel): + name: str | None = None + + @router.patch("/{playlist_id}") def rename_playlist( playlist_id: int, - payload: dict, + body: RenamePlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) - if "name" in payload: - name = (payload.get("name") or "").strip() + if "name" in body.model_fields_set: + name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="name cannot be empty") if name != pl.name: @@ -469,13 +476,13 @@ def delete_playlist( @router.post("/{playlist_id}/items") def add_item( playlist_id: int, - payload: dict, + body: VideoRefIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) - video_id = payload.get("video_id") - if not video_id or db.get(Video, video_id) is None: + video_id = body.video_id + if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") _add_item(db, pl, video_id, mark_dirty=True) return {"playlist_id": pl.id, "video_id": video_id} @@ -493,15 +500,19 @@ def remove_item( return {"playlist_id": pl.id, "video_id": video_id} +class ReorderItemsIn(BaseModel): + video_ids: list[str] = [] + + @router.put("/{playlist_id}/order") def reorder_items( playlist_id: int, - payload: dict, + body: ReorderItemsIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) - order = payload.get("video_ids") or [] + order = body.video_ids items = { it.video_id: it for it in db.execute( diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 2f82edf..8c67e0e 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -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 @@ -47,8 +48,10 @@ from app.plex import stream as plex_stream from app.plex import sync as plex_sync from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.routes._common import owned_or_404 from app.routes.admin import admin_user from app.routes.feed import _to_tsquery_str +from app.utils import iso log = logging.getLogger("siftlode.plex") @@ -141,11 +144,7 @@ def _link_dict(link: PlexLink | None) -> dict: "sync_enabled": bool(link and link.sync_enabled), "uses_admin": bool(link and link.uses_admin), "initial_import_done": bool(link and link.initial_import_done), - "last_watch_sync_at": ( - link.last_watch_sync_at.isoformat() - if link and link.last_watch_sync_at - else None - ), + "last_watch_sync_at": iso(link.last_watch_sync_at) if link else None, } @@ -158,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: @@ -169,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)) @@ -799,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") @@ -818,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) @@ -835,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) @@ -852,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: @@ -872,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) @@ -889,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 @@ -912,10 +931,7 @@ def collection_set_editable( # --- Playlists (Siftlode-native, per-user, ordered — no Plex account needed; Plex sync is a later phase). def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist: - pl = db.query(PlexPlaylist).filter_by(id=pid, user_id=user.id).first() - if pl is None: - raise HTTPException(status_code=404, detail="Playlist not found") - return pl + return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found") def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: @@ -980,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: @@ -1027,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 @@ -1048,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() @@ -1074,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)] @@ -1091,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 @@ -1114,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 = ( @@ -1134,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) @@ -1897,10 +1942,18 @@ def _push_watch( ) +class ItemProgressIn(BaseModel): + # float, truncated in the handler — the player may post a raw currentTime (a float); a plain + # `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated). + position_seconds: float = 0 + duration_seconds: float = 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), @@ -1911,12 +1964,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, int(body.position_seconds)) + duration = int(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" @@ -1962,10 +2012,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), @@ -1974,7 +2028,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() @@ -2040,10 +2094,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), @@ -2052,7 +2111,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} @@ -2061,7 +2120,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), @@ -2070,7 +2129,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} diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index e7c3d0c..4d39459 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -8,6 +8,7 @@ a short-lived signed grant that the media URL carries (a `