Merge R6 · API contract & transaction ownership → 0.55.0
Backend-heavy, frontend-invisible hardening over four sprints, all user-triggered /code-review clean + UAT-passed on localdev: - S1a: shared iso()/owned_or_404/require_write_scope helpers. - S1b: all ~52 payload:dict endpoints → Pydantic request models (3 named prod-500s on garbage input become clean 422s); status-code unification. - S3 (pulled ahead of S2): DB-backed pytest lane (ephemeral Postgres, alembic head) wired into the gate; /facets genre-null regression; migration 0060 normalises existing JSONB-null plex rows. - S2: quota accounting owns its session (durable across a caller rollback, never commits the caller); Plex sync commits per-section. Proven by DB-lane tests. Suite 159 -> 181 (incl. the new DB lane). db_revision 0060. No response-shape change.
This commit is contained in:
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
key→value 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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user