Merge: promote dev to prod

This commit is contained in:
2026-07-26 21:30:05 +02:00
35 changed files with 1076 additions and 303 deletions
+1 -1
View File
@@ -1 +1 @@
0.54.0
0.55.0
@@ -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
+13
View File
@@ -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."""
+4 -1
View File
@@ -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:
+32 -14
View File
@@ -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()
+44
View File
@@ -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
+32 -14
View File
@@ -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)
+2 -1
View File
@@ -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,
+41 -36
View File
@@ -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}")
+12 -3
View File
@@ -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
+125 -73
View File
@@ -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)
+21 -10
View File
@@ -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,
}
+7 -2
View File
@@ -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)
+3 -2
View File
@@ -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),
}
+2 -1
View File
@@ -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),
}
+37 -26
View File
@@ -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(
+113 -54
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
@@ -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}
+7 -2
View File
@@ -8,6 +8,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -79,8 +80,12 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
}
class WatchUnlockIn(BaseModel):
password: str = ""
@router.post("/watch/{token}/unlock")
def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> dict:
def watch_unlock(token: str, body: WatchUnlockIn, db: Session = Depends(get_db)) -> dict:
"""Exchange the link password for a signed grant carried by the media URL."""
if not _unlock_limit.allow(token):
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
@@ -92,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
"needs_password": False,
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
}
if not verify_password((payload.get("password") or ""), link.password_hash):
if not verify_password(body.password, link.password_hash):
raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link)
link.view_count += 1
+34 -23
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -6,6 +7,7 @@ from sqlalchemy.orm import Session
from app.auth import require_human
from app.db import get_db
from app.models import SavedView, User
from app.routes._common import owned_or_404
# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
# require_human so the shared demo account can't pollute a communal list (mirrors how other
@@ -39,25 +41,27 @@ def list_views(
return [_serialize(v) for v in rows]
class CreateViewIn(BaseModel):
name: str
filters: dict
@router.post("")
def create_view(
payload: dict,
body: CreateViewIn,
user: User = Depends(require_human),
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")
filters = payload.get("filters")
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object")
maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where(
SavedView.user_id == user.id
)
).scalar_one()
view = SavedView(
user_id=user.id, name=name[:80], filters=filters, position=maxpos + 1
user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
)
db.add(view)
try:
@@ -71,32 +75,37 @@ def create_view(
def _own_view(db: Session, user: User, view_id: int) -> SavedView:
view = db.get(SavedView, view_id)
if view is None or view.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown view")
return view
return owned_or_404(db, SavedView, view_id, user, detail="Unknown view")
class UpdateViewIn(BaseModel):
# PATCH: presence decides what's touched (read via model_fields_set). filters, when sent,
# must be a real object — an explicit null is rejected (a view can't have null filters).
name: str | None = None
filters: dict | None = None
is_default: bool | None = None
@router.patch("/{view_id}")
def update_view(
view_id: int,
payload: dict,
body: UpdateViewIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
view = _own_view(db, user, view_id)
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="name cannot be empty")
view.name = name[:80]
if "filters" in payload:
filters = payload.get("filters")
if not isinstance(filters, dict):
if "filters" in sent:
if body.filters is None:
raise HTTPException(status_code=400, detail="filters must be an object")
view.filters = filters
if "is_default" in payload:
make_default = bool(payload.get("is_default"))
view.filters = body.filters
if "is_default" in sent:
make_default = bool(body.is_default)
if make_default:
# At most one default per user: clear the flag on the others first.
db.execute(
@@ -127,15 +136,17 @@ def delete_view(
return {"deleted": view_id}
class ReorderViewsIn(BaseModel):
ids: list[int]
@router.post("/reorder")
def reorder_views(
payload: dict,
body: ReorderViewsIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
ids = payload.get("ids")
if not isinstance(ids, list):
raise HTTPException(status_code=400, detail="ids must be a list")
ids = body.ids
rows = (
db.execute(select(SavedView).where(SavedView.user_id == user.id))
.scalars()
+13 -10
View File
@@ -2,6 +2,7 @@
per-job run activity (from the in-process scheduler), the work still queued (DB-derived),
and the shared quota picture."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
@@ -25,10 +26,14 @@ from app.sync.runner import estimate_deep_backfill
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
class JobIntervalIn(BaseModel):
interval_minutes: int
@router.patch("/jobs/{job_id}")
def set_job_interval(
job_id: str,
payload: dict,
body: JobIntervalIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
@@ -36,10 +41,7 @@ def set_job_interval(
to the live scheduler immediately."""
if job_id not in JOB_INTERVALS:
raise HTTPException(status_code=404, detail="Unknown job")
try:
minutes = int(payload.get("interval_minutes"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="interval_minutes must be a number")
minutes = body.interval_minutes
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
raise HTTPException(
status_code=400,
@@ -77,18 +79,19 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
return {"started": trigger_all(actor_id=user.id)}
class MaintenanceSettingsIn(BaseModel):
revalidate_batch: int
@router.patch("/maintenance")
def set_maintenance_settings(
payload: dict,
body: MaintenanceSettingsIn,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
run). Persisted to app_state; the job reads it each run."""
try:
value = int(payload.get("revalidate_batch"))
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="revalidate_batch must be a number")
value = body.revalidate_batch
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
raise HTTPException(
status_code=400,
+8 -3
View File
@@ -16,6 +16,7 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased
@@ -289,7 +290,7 @@ def search_youtube(
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap
# counting user searches instead of internal continuation pages.
if source != "api":
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
ordered = collected[:limit]
@@ -323,9 +324,13 @@ def search_youtube(
}
class ClearSearchIn(BaseModel):
video_ids: list[str] = []
@router.post("/clear")
def clear_search(
payload: dict,
body: ClearSearchIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
@@ -333,7 +338,7 @@ def clear_search(
for them, then hard-delete the now-orphaned ones a via_search video nobody KEPT (no watch
state, not playlisted, channel not subscribed) and that no other user still has as a search
find. Kept videos and organically-owned ones are left untouched."""
ids = [str(v) for v in (payload.get("video_ids") or [])]
ids = body.video_ids
if not ids:
return {"removed": 0}
db.execute(
+24 -8
View File
@@ -5,7 +5,10 @@ mutating step goes through `require_setup`: it rejects once the instance is conf
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
"""
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -45,12 +48,20 @@ def setup_info(db: Session = Depends(require_setup)) -> dict:
return {"secrets_manageable": sysconfig.secrets_manageable()}
class SetupAdminIn(BaseModel):
# Both default to "" so a missing field stays the wizard's own 400 ("Enter a valid email" /
# password too short) rather than a 422. `password: str` also stops a non-string value from
# reaching argon2 (which would 500 in len()/hash_password) — it's now a clean 422.
email: str = ""
password: str = ""
@router.post("/admin")
def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dict:
"""Create (or update) the first administrator. Active + verified immediately — the operator is
configuring the instance, so there's no email-verification / approval gate here."""
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
email = body.email.strip().lower()
password = body.password
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
@@ -70,11 +81,12 @@ def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict:
@router.post("/config")
def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict:
"""Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the
sysconfig registry; empty values are skipped (left at the default)."""
sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic
keyvalue map, so it stays a typed dict rather than a fixed model."""
saved: list[str] = []
for key, value in (payload or {}).items():
for key, value in settings_map.items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "":
@@ -87,10 +99,14 @@ def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict:
return {"saved": saved}
class SetupTestEmailIn(BaseModel):
to: str = ""
@router.post("/test-email")
def setup_test_email(payload: dict, db: Session = Depends(require_setup)) -> dict:
def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup)) -> dict:
"""Send a test email to confirm the SMTP settings the wizard just saved."""
to = (payload.get("to") or "").strip()
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
+29 -15
View File
@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -6,6 +7,7 @@ from sqlalchemy.orm import Session
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.routes._common import owned_or_404
from app.sync.autotag import run_autotag_all
router = APIRouter(prefix="/api/tags", tags=["tags"])
@@ -33,21 +35,28 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
]
class TagCreateIn(BaseModel):
name: str
category: str = "other"
color: str | None = None
@router.post("")
def create_tag(
payload: dict,
body: TagCreateIn,
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")
category = payload.get("category") or "other"
# An unknown category silently falls back to "other" (unchanged behaviour) rather than 422.
category = body.category if body.category in ("language", "topic", "other") else "other"
tag = Tag(
user_id=user.id,
name=name,
color=payload.get("color"),
category=category if category in ("language", "topic", "other") else "other",
color=body.color,
category=category,
)
db.add(tag)
try:
@@ -59,28 +68,33 @@ def create_tag(
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
tag = db.get(Tag, tag_id)
if tag is None or tag.user_id != user.id:
# System tags (user_id NULL) are not user-editable.
raise HTTPException(status_code=404, detail="Unknown tag")
return tag
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
return owned_or_404(db, Tag, tag_id, user, detail="Unknown tag")
class TagUpdateIn(BaseModel):
# PATCH: presence matters, and `color: null` explicitly CLEARS it — so the endpoint reads
# model_fields_set rather than testing for None (which couldn't tell absent from cleared).
name: str | None = None
color: str | None = None
@router.patch("/{tag_id}")
def update_tag(
tag_id: int,
payload: dict,
body: TagUpdateIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
tag = _own_tag(db, user, tag_id)
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="name cannot be empty")
tag.name = name
if "color" in payload:
tag.color = payload.get("color")
if "color" in sent:
tag.color = body.color
try:
db.commit()
except IntegrityError:
+9 -1
View File
@@ -1,6 +1,14 @@
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
import re
from datetime import datetime, timezone
from datetime import date, datetime, timezone
def iso(dt: date | None) -> str | None:
"""Serialize a date/datetime for a JSON response: ISO-8601, or None when the column is
NULL. Single source of truth for the `x.isoformat() if x else None` idiom the routes had
hand-rolled ~17 times so a future tz/format tweak lands in one place. Accepts `date`
too (datetime is a subclass) e.g. MediaAsset.upload_date is a plain `date`."""
return dt.isoformat() if dt is not None else None
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep
+3 -3
View File
@@ -113,7 +113,7 @@ class YouTubeClient:
else:
headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(self.db, cost)
quota.record_usage(cost)
if resp.status_code != 200:
log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200])
raise YouTubeError(f"GET {path} -> {resp.status_code}: {resp.text[:300]}")
@@ -239,7 +239,7 @@ class YouTubeClient:
params={"id": subscription_id},
headers={"Authorization": f"Bearer {self._access_token()}"},
)
quota.record_usage(self.db, 50)
quota.record_usage(50)
if resp.status_code not in (200, 204):
log.warning(
"YouTube subscriptions.delete -> %s: %s",
@@ -282,7 +282,7 @@ class YouTubeClient:
resp = self._send(
method, f"{API_BASE}/{path}", params=params, json=json, headers=headers
)
quota.record_usage(self.db, 50)
quota.record_usage(50)
if resp.status_code not in (200, 204):
log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200])
raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}")
+35
View File
@@ -0,0 +1,35 @@
# DB-backed pytest lane (R6 S3b): an ephemeral Postgres + the test image, wired together so the
# `db` fixture (tests/conftest.py) has a real database. The whole thing is throwaway — the pg data
# lives in tmpfs (RAM) and nothing is persisted. The pure-logic lane still runs WITHOUT this file
# (plain `docker run … python -m pytest` — DB tests skip when no Postgres is reachable).
#
# docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit \
# --exit-code-from test
# docker compose -f backend/docker-compose.test.yml down -v
services:
testdb:
image: postgres:16-alpine
environment:
POSTGRES_USER: siftlode
POSTGRES_PASSWORD: siftlode
POSTGRES_DB: siftlode_test
tmpfs:
- /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run
healthcheck:
test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"]
interval: 1s
timeout: 3s
retries: 30
test:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
testdb:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@testdb:5432/siftlode_test
volumes:
- ./:/app
working_dir: /app
command: python -m pytest
+72
View File
@@ -0,0 +1,72 @@
"""Pytest fixtures for the DB-backed integration lane (R6 S3b).
The bulk of the suite is pure-logic and needs no database. This adds an OPT-IN `db` fixture
that hands a test a real session against a throwaway Postgres (the app's own engine, bound to
the test `DATABASE_URL`). When no database is reachable the default `siftlode check` still
runs the pure-logic tests with no Postgres any test that asks for `db` is SKIPPED, so the
pure-logic lane is unaffected.
Schema comes from `alembic upgrade head` not `create_all` because the real schema depends on
things only migrations set up (the `unaccent` extension + the `public.unaccent_simple` text-search
config the videos.search_vector generated column needs). Building it via migrations also validates
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so
tests that COMMIT (the whole point of this lane transaction-ownership behaviour) don't leak.
"""
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
import app.models # noqa: F401 — register every model on Base.metadata
from app.config import settings
from app.db import Base, SessionLocal, engine
def _db_reachable() -> bool:
# A throwaway engine with a bounded connect_timeout so a slow/firewalled DATABASE_URL can't
# hang the whole suite at import (the app engine has no connect timeout). The compose lane
# gates on `service_healthy`, but this keeps a manual run honest too.
probe = create_engine(settings.database_url, connect_args={"connect_timeout": 3})
try:
with probe.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception:
return False
finally:
probe.dispose()
_DB_REACHABLE = _db_reachable()
@pytest.fixture(scope="session")
def _schema():
if not _DB_REACHABLE:
pytest.skip("no test database (set DATABASE_URL to a reachable Postgres)")
# Build the real prod schema via migrations (alembic env.py reads settings.database_url, i.e.
# the test DATABASE_URL). Idempotent — a re-run against an already-migrated DB is a no-op.
command.upgrade(Config("alembic.ini"), "head")
yield
def _truncate_all():
# RESTART IDENTITY so serial PKs are deterministic per test; CASCADE handles FKs. Truncating
# BEFORE each test (not after) means every test — including the first, which would otherwise see
# migration-seeded rows — starts from the same empty slate and seeds only what it needs.
tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
with engine.begin() as conn:
conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
@pytest.fixture
def db(_schema):
"""A real session against the test Postgres, on a clean (empty) database. The code under test
may commit freely."""
_truncate_all()
session = SessionLocal()
try:
yield session
finally:
session.rollback()
session.close()
+21
View File
@@ -0,0 +1,21 @@
"""Smoke test proving the DB-backed lane works (R6 S3b): a real commit round-trips, and each
test starts from a clean database (TRUNCATE isolation), which is what lets the transaction-
ownership tests (R6 S2) trust what they commit."""
from sqlalchemy import func, select
from app.models import User
def test_commit_roundtrips(db):
db.add(User(email="lane@example.com", role="admin"))
db.commit()
got = db.execute(select(User).where(User.email == "lane@example.com")).scalar_one()
assert got.id is not None
assert got.role == "admin"
assert got.is_active is True # server_default applied on flush/commit
def test_isolation_starts_clean(db):
# If the previous test's committed row leaked, this count would be 1 — TRUNCATE must reset it.
assert db.scalar(select(func.count()).select_from(User)) == 0
+45
View File
@@ -0,0 +1,45 @@
"""DB-backed regression for the 0.53.1 hotfix (R6 S3b-test): GET /api/plex/facets?scope=show
must return 200 not the "cannot extract elements from a scalar" 500 for a metadata-poor
library whose show has a JSONB-scalar `null` genres (a pre-0.53.1 row). The endpoint's
`jsonb_typeof(genres)='array'` read-guard is what makes it safe; this test would 500 without it.
Uses the DB lane (`db` fixture) skipped when no Postgres is reachable.
"""
from fastapi.testclient import TestClient
from sqlalchemy import text
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexLibrary, PlexShow, User
def test_facets_survives_json_null_genres(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="No Genre Show"))
user = User(email="facets@example.com")
db.add(user)
db.commit()
# The request gate 503s an unconfigured instance; flip the process-level "configured" flag the
# gate reads. monkeypatch auto-restores it, so it can't leak "configured" into another test.
monkeypatch.setattr(state, "_configured_cache", True)
# Simulate a pre-0.53.1 row: genres persisted as a JSONB scalar `null` (what SQLAlchemy wrote
# before none_as_null=True), NOT SQL NULL — this is the value jsonb_array_elements_text() chokes
# on. A Python None now maps to SQL NULL, so we set the scalar directly.
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
db.commit()
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get("/api/plex/facets?scope=show")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
# The scalar-null show contributes no genres — and, crucially, doesn't abort the whole endpoint.
assert resp.json()["genres"] == []
@@ -0,0 +1,44 @@
"""DB-backed test for the S3a data normalization (migration 0060): a JSONB scalar `null` in the
plex tag columns becomes SQL NULL, while a real array is left untouched. Runs the migration's exact
UPDATE statements against seeded rows (the migration itself is a no-op in the empty test DB).
Uses the DB lane (`db` fixture) skipped when no Postgres is reachable.
"""
from sqlalchemy import text
from app.models import PlexLibrary, PlexShow
# Mirror of migration 0060's statements (plex_items runs against an empty table here — proving the
# SQL is valid for both tables; the assertions below check the seeded plex_shows rows).
_NORMALIZE = [
f"UPDATE {t} SET {c} = NULL WHERE jsonb_typeof({c}) = 'null'"
for t in ("plex_items", "plex_shows")
for c in ("genres", "directors", "cast_names", "collection_keys")
]
def test_scalar_null_normalized_array_preserved(db):
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexShow(rating_key="null_row", library_id=lib.id, title="Null genres"),
PlexShow(rating_key="array_row", library_id=lib.id, title="Real genres"),
]
)
db.commit()
# Set a pre-0.53.1 JSONB scalar null on one, a genuine array on the other.
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 'null_row'"))
db.execute(text("""UPDATE plex_shows SET genres = '["Drama"]'::jsonb WHERE rating_key = 'array_row'"""))
db.commit()
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='null_row'")) == "null"
for stmt in _NORMALIZE:
db.execute(text(stmt))
db.commit()
# The scalar null is now SQL NULL (jsonb_typeof of SQL NULL is itself NULL, not 'null')...
assert db.scalar(text("SELECT genres IS NULL FROM plex_shows WHERE rating_key='null_row'")) is True
# ...and a real array is untouched.
assert db.scalar(text("SELECT jsonb_typeof(genres) FROM plex_shows WHERE rating_key='array_row'")) == "array"
+44
View File
@@ -0,0 +1,44 @@
"""DB-backed test for R6 S2's Plex per-section commit: a PlexError on a LATER library must keep the
libraries already mirrored (committed per section), not roll the whole reconcile back. Uses the DB
lane (`db` fixture) with a faked PlexClient skipped without Postgres.
"""
from sqlalchemy import select
from app.plex import sync
from app.plex.client import PlexError
from app.models import PlexLibrary
class _FakePlex:
def __enter__(self):
return self
def __exit__(self, *exc):
return False # don't suppress the PlexError
def sections(self):
return [
{"type": "movie", "key": "1", "title": "Lib One"},
{"type": "movie", "key": "2", "title": "Lib Two"},
]
def test_late_section_failure_keeps_earlier_sections(db, monkeypatch):
monkeypatch.setattr(sync.sysconfig, "get_bool", lambda *a, **k: True) # plex_enabled
monkeypatch.setattr(sync, "_enabled_section_keys", lambda db: None) # all sections wanted
monkeypatch.setattr(sync, "PlexClient", lambda db: _FakePlex())
monkeypatch.setattr(sync, "_sync_movies", lambda *a, **k: None) # skip the real Plex fetch
# Section 1 completes and commits; section 2's collections step raises → its work rolls back.
def collections(db, plex, lib, stats):
if lib.plex_key == "2":
raise PlexError("boom on the second library")
monkeypatch.setattr(sync, "_sync_collections", collections)
result = sync.sync(db)
assert "error" in result # the run reports the failure honestly
# Section 1's library survived the section-2 failure (per-section commit); section 2 rolled back.
keys = db.scalars(select(PlexLibrary.plex_key).order_by(PlexLibrary.plex_key)).all()
assert keys == ["1"]
+57
View File
@@ -0,0 +1,57 @@
"""DB-backed tests for R6 S2 quota transaction ownership: quota accounting writes in its OWN
session, so a spend is DURABLE even when the calling job rolls back (the units were really consumed
at YouTube), and it NEVER commits the caller's session (the old behaviour flushed a job's partial
state mid-run behind its own rollback). Uses the DB lane (`db` fixture) skipped without Postgres.
"""
from sqlalchemy import func, select
from app import quota
from app.models import QuotaEvent, User
def test_record_usage_survives_caller_rollback_and_leaves_caller_uncommitted(db):
# The caller has uncommitted work of its own; then a YouTube call spends quota; then the caller
# rolls back (a failed job).
db.add(User(email="pending@example.com")) # caller's own, never committed
quota.record_usage(10) # dedicated session — commits independently
db.rollback() # the job fails and rolls back
# Quota spend PERSISTED (real units were consumed) — the guard stays accurate ...
assert quota.units_used_today(db) == 10
assert db.scalar(select(func.count()).select_from(QuotaEvent)) == 1
# ... but the caller's own partial work did NOT leak (quota never committed the caller's session).
assert db.scalar(select(func.count()).select_from(User).where(User.email == "pending@example.com")) == 0
def test_record_usage_accumulates(db):
quota.record_usage(10)
quota.record_usage(5)
assert quota.units_used_today(db) == 15 # atomic upsert: +10 then +5
def test_reads_see_separate_session_spend_mid_transaction(db):
# Mimics measured()'s before/after diff: a read, a spend (in quota's SEPARATE session), a read.
# The second read must reflect the spend even though the caller's own transaction is still open —
# units_used_today is a scalar SELECT (READ COMMITTED sees other sessions' commits), NOT a cached
# ORM row that db.get would return stale.
before = quota.units_used_today(db)
quota.record_usage(7)
after = quota.units_used_today(db)
assert after - before == 7
def test_log_action_survives_caller_rollback(db):
user = User(email="searcher@example.com")
db.add(user)
db.commit() # a committed user to attribute the (free) action to
db.add(User(email="pending@example.com")) # uncommitted caller work
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
db.rollback()
# The zero-cost action event survived (per-user daily caps must still count it) ...
events = db.execute(select(QuotaEvent)).scalars().all()
assert len(events) == 1
assert events[0].user_id == user.id
assert events[0].units == 0
# ... and the caller's uncommitted user is gone.
assert db.scalar(select(func.count()).select_from(User)) == 1
+67
View File
@@ -0,0 +1,67 @@
"""R6 S1b: the request models turn payloads that used to 500 into clean validation errors,
and PATCH models distinguish an absent field from an explicit null. Pure-logic (no DB) it
exercises the Pydantic models the route modules define, not the endpoints."""
import pytest
from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn
from app.routes.feed import VideoProgressIn
from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn
# --- The three ROADMAP-named 500-producers are now 422s (ValidationError at the boundary). ---
def test_channel_priority_rejects_non_numeric():
# Was `int(payload["priority"])` → 500 on garbage. Now a validation error.
with pytest.raises(ValidationError):
ChannelUpdateIn(priority="not-a-number")
def test_channel_priority_accepts_int_and_numeric_string():
assert ChannelUpdateIn(priority=5).priority == 5
assert ChannelUpdateIn(priority="5").priority == 5 # lax numeric-string coercion, as before
def test_channel_update_fields_all_optional():
m = ChannelUpdateIn()
assert m.priority is None and m.hidden is None and m.deep_requested is None
def test_attach_tag_rejects_non_int_id():
# Was a str tag_id into db.get(Tag, ...) — a DB cast 500. Now rejected up front.
with pytest.raises(ValidationError):
AttachTagIn(tag_id="abc")
def test_setup_admin_password_must_be_a_string():
# Was a non-string password reaching len()/argon2 → 500. `str` rejects it.
with pytest.raises(ValidationError):
SetupAdminIn(email="a@b.c", password=12345)
def test_admin_quota_rejects_non_numeric_limit():
# Was `int(payload["max_bytes"])` → 500. Now a validation error.
with pytest.raises(ValidationError):
AdminSetQuotaIn(max_bytes="lots")
def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path. (code-review round 1.)
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError):
VideoProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
def test_tag_update_absent_vs_null_color():
absent = TagUpdateIn()
assert "color" not in absent.model_fields_set # untouched on PATCH
cleared = TagUpdateIn(color=None)
assert "color" in cleared.model_fields_set # an explicit null CLEARS it
assert cleared.color is None
+30
View File
@@ -0,0 +1,30 @@
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
from datetime import date, datetime, timezone
from app.utils import iso
def test_none_maps_to_none():
assert iso(None) is None
def test_plain_date_serializes_as_iso_date():
# MediaAsset.upload_date is a `date`, not a datetime — the contract must accept it.
assert iso(date(2026, 7, 26)) == "2026-07-26"
def test_aware_datetime_keeps_offset():
dt = datetime(2026, 7, 26, 12, 30, 0, tzinfo=timezone.utc)
assert iso(dt) == "2026-07-26T12:30:00+00:00"
def test_naive_datetime_serializes_without_offset():
dt = datetime(2026, 7, 26, 12, 30, 0)
assert iso(dt) == "2026-07-26T12:30:00"
def test_matches_datetime_isoformat_contract():
# iso is exactly `dt.isoformat()` for a present value — guards the routes that used to
# hand-roll `x.isoformat() if x else None`.
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
assert iso(dt) == dt.isoformat()
+11
View File
@@ -14,6 +14,17 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.55.0",
date: "2026-07-26",
summary:
"Internal hardening — every write request is type-checked at the edge, quota accounting and Plex sync hold up cleanly when something fails partway, and a new database-backed test net guards it all. No changes to normal use.",
chores: [
"API contract: every mutating endpoint (~50) now validates its request body against a typed request model at the edge, replacing scattered hand-written checks. Malformed input is rejected consistently with a clear validation error instead of occasionally causing an internal error, and response status codes were made consistent across modules.",
"Transaction correctness: YouTube quota accounting now writes in its own transaction — it stays accurate even if the surrounding job rolls back, and no longer commits a job's partial work early. Plex library sync commits each library as it finishes, so a failure partway through keeps the libraries already mirrored. Existing genre-less Plex rows were normalised.",
"Testing: a database-backed integration test lane (an ephemeral Postgres spun up for the test run) now runs in the release gate, covering the transaction behaviour and the Plex genre-less-library fix that the pure-logic tests couldn't reach.",
],
},
{
version: "0.54.0",
date: "2026-07-26",