fix(api): mutations answer ok; route helpers flush and leave the commit to the handler
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:
- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
`{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
mark, and the invite decision + account activation — which an approval used to land in separate
transactions. The other six commit on purpose (a token durable before its link is mailed, a
single-use token burned as its own unit, the Google callback's tail, a background registration
with its own session, the staged search ingest around YouTube calls), each listed with its reason.
`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
This commit is contained in:
@@ -82,8 +82,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
|
||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
|||||||
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
||||||
if user is not None and not user.is_active:
|
if user is not None and not user.is_active:
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
|
|
||||||
|
|
||||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||||
@@ -192,7 +192,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|||||||
target_id=inv.id,
|
target_id=inv.id,
|
||||||
summary=inv.email,
|
summary=inv.email,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.flush() # the handler commits, with the activation that goes with it
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +211,9 @@ def approve_invite(
|
|||||||
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
||||||
lang = mailtext.lang_for_email(db, inv.email)
|
lang = mailtext.lang_for_email(db, inv.email)
|
||||||
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
||||||
|
# Before the response, so the mail (a background task, after it) never describes a decision
|
||||||
|
# the database does not hold yet.
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +230,7 @@ def deny_invite(
|
|||||||
background.add_task(
|
background.add_task(
|
||||||
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
||||||
)
|
)
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -265,6 +269,7 @@ def add_invite(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
_activate_user_by_email(db, email)
|
_activate_user_by_email(db, email)
|
||||||
|
db.commit()
|
||||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||||
|
|
||||||
|
|
||||||
@@ -831,7 +836,7 @@ def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
|||||||
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded"
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"reset": True, "playlists_seeded": seeded}
|
return {"ok": True, "reset": True, "playlists_seeded": seeded}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/purge-discovery")
|
@router.post("/purge-discovery")
|
||||||
|
|||||||
@@ -68,4 +68,4 @@ def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||||||
n = db.execute(delete(AuditLog)).rowcount or 0
|
n = db.execute(delete(AuditLog)).rowcount or 0
|
||||||
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"cleared": n}
|
return {"ok": True, "cleared": n}
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
|||||||
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
||||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||||
if not _demo_limiter.allow(client_key(request)):
|
if not _demo_limiter.allow(client_key(request)):
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": False}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email) and is_demo_allowed(db, email):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
@@ -684,8 +684,8 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
|
|||||||
_remember_epoch(request, demo)
|
_remember_epoch(request, demo)
|
||||||
# The demo key IS an address, so it is not in the line — see the denied branch above.
|
# The demo key IS an address, so it is not in the line — see the denied branch above.
|
||||||
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
|
log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
|
||||||
return {"authenticated": True}
|
return {"ok": True, "authenticated": True}
|
||||||
return {"authenticated": False}
|
return {"ok": True, "authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
def _establish_session(request: Request, user: User) -> None:
|
def _establish_session(request: Request, user: User) -> None:
|
||||||
@@ -822,14 +822,14 @@ def register(
|
|||||||
if not sysconfig.get_bool(db, "allow_registration"):
|
if not sysconfig.get_bool(db, "allow_registration"):
|
||||||
raise refused(403, "registration_closed")
|
raise refused(403, "registration_closed")
|
||||||
if not _register_limiter.allow(client_key(request)):
|
if not _register_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||||
|
|
||||||
# Do the existence check + account creation OFF the response path (a background task with its own
|
# Do the existence check + account creation OFF the response path (a background task with its own
|
||||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||||
background.add_task(_register_account, email, password, body.lang)
|
background.add_task(_register_account, email, password, body.lang)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||||
@@ -995,11 +995,11 @@ def verify_email_resend(
|
|||||||
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
||||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||||
if not _verify_resend_limiter.allow(client_key(request)):
|
if not _verify_resend_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"ok": True, "status": "ok"} # silently throttle; uniform response
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_resend_verify_if_eligible, email)
|
background.add_task(_resend_verify_if_eligible, email)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _resend_verify_if_eligible(email: str) -> None:
|
def _resend_verify_if_eligible(email: str) -> None:
|
||||||
@@ -1089,14 +1089,14 @@ def password_reset_request(
|
|||||||
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
|
||||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||||
if not _reset_limiter.allow(client_key(request)):
|
if not _reset_limiter.allow(client_key(request)):
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
email = (body.email or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||||
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||||
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_send_reset_if_eligible, email)
|
background.add_task(_send_reset_if_eligible, email)
|
||||||
return {"status": "ok"}
|
return {"ok": True, "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
def _send_reset_if_eligible(email: str) -> None:
|
def _send_reset_if_eligible(email: str) -> None:
|
||||||
|
|||||||
@@ -424,6 +424,7 @@ def explore_channel(
|
|||||||
except YouTubeError as exc:
|
except YouTubeError as exc:
|
||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"ingested": result["ingested"],
|
"ingested": result["ingested"],
|
||||||
"next_page_token": result["next_page_token"],
|
"next_page_token": result["next_page_token"],
|
||||||
@@ -505,6 +506,7 @@ def update_channel(
|
|||||||
background.add_task(backfill_channel_now, channel_id, user.id)
|
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"id": channel_id,
|
"id": channel_id,
|
||||||
"priority": sub.priority,
|
"priority": sub.priority,
|
||||||
"hidden": sub.hidden,
|
"hidden": sub.hidden,
|
||||||
@@ -539,7 +541,7 @@ def reset_backfill(
|
|||||||
db.commit()
|
db.commit()
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
return {"id": channel_id, "reset": True}
|
return {"ok": True, "id": channel_id, "reset": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{channel_id}/subscribe")
|
@router.post("/{channel_id}/subscribe")
|
||||||
@@ -618,7 +620,7 @@ def subscribe(
|
|||||||
.values(via_explore=False)
|
.values(via_explore=False)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"subscribed": channel_id}
|
return {"ok": True, "subscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/subscription")
|
@router.delete("/{channel_id}/subscription")
|
||||||
@@ -645,7 +647,7 @@ def unsubscribe(
|
|||||||
raise youtube_refusal(exc)
|
raise youtube_refusal(exc)
|
||||||
db.delete(sub)
|
db.delete(sub)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unsubscribed": channel_id}
|
return {"ok": True, "unsubscribed": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/blocked/list")
|
@router.get("/blocked/list")
|
||||||
@@ -686,7 +688,7 @@ def block_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"blocked": channel_id}
|
return {"ok": True, "blocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/block")
|
@router.delete("/{channel_id}/block")
|
||||||
@@ -701,7 +703,7 @@ def unblock_channel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"unblocked": channel_id}
|
return {"ok": True, "unblocked": channel_id}
|
||||||
|
|
||||||
|
|
||||||
class AttachTagIn(BaseModel):
|
class AttachTagIn(BaseModel):
|
||||||
@@ -736,7 +738,7 @@ def attach_tag(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"channel_id": channel_id, "tag_id": body.tag_id}
|
return {"ok": True, "channel_id": channel_id, "tag_id": body.tag_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{channel_id}/tags/{tag_id}")
|
@router.delete("/{channel_id}/tags/{tag_id}")
|
||||||
@@ -756,4 +758,4 @@ def detach_tag(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"channel_id": channel_id, "tag_id": tag_id}
|
return {"ok": True, "channel_id": channel_id, "tag_id": tag_id}
|
||||||
|
|||||||
@@ -130,4 +130,4 @@ def send_test_email(
|
|||||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||||
if not sent:
|
if not sent:
|
||||||
raise refused(422, "smtp_failed")
|
raise refused(422, "smtp_failed")
|
||||||
return {"sent": True, "to": user.email}
|
return {"ok": True, "sent": True, "to": user.email}
|
||||||
|
|||||||
@@ -847,7 +847,7 @@ def share_download(
|
|||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"shared_with": recipient.email}
|
return {"ok": True, "shared_with": recipient.email}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}/share/{email}")
|
@router.delete("/{job_id}/share/{email}")
|
||||||
@@ -891,7 +891,7 @@ def remove_shared_with_me(
|
|||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"removed": job_id}
|
return {"ok": True, "removed": job_id}
|
||||||
|
|
||||||
|
|
||||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ def set_video_state(
|
|||||||
# or moved this row. The state the caller asked for is the state that holds either way.
|
# or moved this row. The state the caller asked for is the state that holds either way.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "status": "new"}
|
return {"ok": True, "video_id": video_id, "status": "new"}
|
||||||
|
|
||||||
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
||||||
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
||||||
@@ -282,7 +282,7 @@ def set_video_state(
|
|||||||
)
|
)
|
||||||
db.execute(stmt)
|
db.execute(stmt)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": status}
|
return {"ok": True, "video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}/state")
|
@router.delete("/videos/{video_id}/state")
|
||||||
@@ -304,7 +304,7 @@ def clear_video_state(
|
|||||||
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "status": "new", "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
||||||
|
|
||||||
|
|
||||||
class VideoProgressIn(BaseModel):
|
class VideoProgressIn(BaseModel):
|
||||||
@@ -362,7 +362,7 @@ def set_video_progress(
|
|||||||
# silent-ok: exactly the race the comment above describes; the response stays true.
|
# silent-ok: exactly the race the comment above describes; the response stays true.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"video_id": video_id, "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
row = VideoState(user_id=user.id, video_id=video_id)
|
row = VideoState(user_id=user.id, video_id=video_id)
|
||||||
@@ -370,7 +370,7 @@ def set_video_progress(
|
|||||||
row.position_seconds = position
|
row.position_seconds = position
|
||||||
row.progress_updated_at = datetime.now(timezone.utc)
|
row.progress_updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"video_id": video_id, "position_seconds": position}
|
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}")
|
@router.get("/videos/{video_id}")
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ def update_preferences(
|
|||||||
user.preferences = merged
|
user.preferences = merged
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"preferences": merged}
|
return {"ok": True, "preferences": merged}
|
||||||
|
|
||||||
|
|
||||||
class DiagnosticsIn(BaseModel):
|
class DiagnosticsIn(BaseModel):
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def setup_keys(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": True}
|
return {"ok": True, "configured": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keys/reset")
|
@router.post("/keys/reset")
|
||||||
@@ -204,7 +204,7 @@ def reset_keys(
|
|||||||
if key is not None:
|
if key is not None:
|
||||||
db.delete(key)
|
db.delete(key)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"configured": False}
|
return {"ok": True, "configured": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/keys/{user_id}")
|
@router.get("/keys/{user_id}")
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
|||||||
if mark_dirty:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
|
|||||||
if mark_dirty:
|
if mark_dirty:
|
||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
|||||||
position=_next_playlist_position(db, user.id),
|
position=_next_playlist_position(db, user.id),
|
||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.flush() # the id, for the item the caller adds next; the handler commits
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
@@ -293,7 +293,8 @@ def add_watch_later(
|
|||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
pl = _watch_later(db, user)
|
pl = _watch_later(db, user)
|
||||||
_add_item(db, pl, video_id, mark_dirty=False)
|
_add_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"saved": True, "playlist_id": pl.id}
|
db.commit()
|
||||||
|
return {"ok": True, "saved": True, "playlist_id": pl.id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/watch-later/{video_id}")
|
@router.delete("/watch-later/{video_id}")
|
||||||
@@ -307,7 +308,8 @@ def remove_watch_later(
|
|||||||
)
|
)
|
||||||
if pl is not None:
|
if pl is not None:
|
||||||
_remove_item(db, pl, video_id, mark_dirty=False)
|
_remove_item(db, pl, video_id, mark_dirty=False)
|
||||||
return {"saved": False}
|
db.commit()
|
||||||
|
return {"ok": True, "saved": False}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-youtube")
|
@router.post("/sync-youtube")
|
||||||
@@ -516,7 +518,8 @@ def add_item(
|
|||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
get_or_404(db, Video, video_id, what="video")
|
get_or_404(db, Video, video_id, what="video")
|
||||||
_add_item(db, pl, video_id, mark_dirty=True)
|
_add_item(db, pl, video_id, mark_dirty=True)
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{playlist_id}/items/{video_id}")
|
@router.delete("/{playlist_id}/items/{video_id}")
|
||||||
@@ -528,7 +531,8 @@ def remove_item(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||||
_remove_item(db, pl, video_id, mark_dirty=True)
|
_remove_item(db, pl, video_id, mark_dirty=True)
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
class MoveItemIn(BaseModel):
|
class MoveItemIn(BaseModel):
|
||||||
@@ -641,4 +645,4 @@ def reorder_items(
|
|||||||
recompute_dirty(db, pl)
|
recompute_dirty(db, pl)
|
||||||
_touch(pl)
|
_touch(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"playlist_id": pl.id, "count": pos}
|
return {"ok": True, "playlist_id": pl.id, "count": pos}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ def watch_link_set(
|
|||||||
result: dict = {}
|
result: dict = {}
|
||||||
if enabled and not link.initial_import_done:
|
if enabled and not link.initial_import_done:
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
return {**_link_dict(link), "import": result or None}
|
return {"ok": True, **_link_dict(link), "import": result or None}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/watch/import")
|
@router.post("/watch/import")
|
||||||
@@ -160,4 +160,4 @@ def watch_import_now(user: User = Depends(admin_user), db: Session = Depends(get
|
|||||||
if link is None or not link.sync_enabled:
|
if link is None or not link.sync_enabled:
|
||||||
raise refused(409, "watch_sync_off")
|
raise refused(409, "watch_sync_off")
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
return {**_link_dict(link), "import": result}
|
return {"ok": True, **_link_dict(link), "import": result}
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ def item_progress(
|
|||||||
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
||||||
if flipped:
|
if flipped:
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
return {"status": "watched", "position_seconds": 0}
|
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
|
|
||||||
st = (
|
st = (
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -402,7 +402,7 @@ def item_progress(
|
|||||||
st.position_seconds = 0
|
st.position_seconds = 0
|
||||||
st.progress_updated_at = now
|
st.progress_updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"position_seconds": 0}
|
return {"ok": True, "position_seconds": 0}
|
||||||
|
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
@@ -427,7 +427,7 @@ def item_progress(
|
|||||||
duration * 1000,
|
duration * 1000,
|
||||||
intent=now,
|
intent=now,
|
||||||
)
|
)
|
||||||
return {"position_seconds": position}
|
return {"ok": True, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
class ItemStateIn(BaseModel):
|
class ItemStateIn(BaseModel):
|
||||||
@@ -473,7 +473,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if mirrored_on_plex:
|
if mirrored_on_plex:
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||||
return {"status": "new"}
|
return {"ok": True, "status": "new"}
|
||||||
if st is None:
|
if st is None:
|
||||||
st = PlexState(user_id=user.id, item_id=it.id)
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
db.add(st)
|
db.add(st)
|
||||||
@@ -485,7 +485,7 @@ def item_state(
|
|||||||
db.commit()
|
db.commit()
|
||||||
if status == "watched" and prev_status != "watched":
|
if status == "watched" and prev_status != "watched":
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
return {"status": status}
|
return {"ok": True, "status": status}
|
||||||
|
|
||||||
|
|
||||||
def _bulk_state(
|
def _bulk_state(
|
||||||
@@ -527,7 +527,9 @@ def _bulk_state(
|
|||||||
db.delete(st)
|
db.delete(st)
|
||||||
if prev != "new":
|
if prev != "new":
|
||||||
to_push.append((e.id, e.rating_key, "unwatched"))
|
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||||
db.commit()
|
# Flush, not commit: the handler commits, and it does so before the response — which is when
|
||||||
|
# the background push below runs, so the push always reads the committed marks.
|
||||||
|
db.flush()
|
||||||
if pushable and to_push:
|
if pushable and to_push:
|
||||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||||
@@ -558,7 +560,8 @@ def show_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
db.commit()
|
||||||
|
return {"ok": True, "changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/season/{rating_key}/state")
|
@router.post("/season/{rating_key}/state")
|
||||||
@@ -580,7 +583,8 @@ def season_state(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
db.commit()
|
||||||
|
return {"ok": True, "changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream/{rating_key}/session")
|
@router.post("/stream/{rating_key}/session")
|
||||||
@@ -605,6 +609,7 @@ def stream_session(
|
|||||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "direct",
|
"mode": "direct",
|
||||||
"url": f"/api/plex/stream/{it.rating_key}/file",
|
"url": f"/api/plex/stream/{it.rating_key}/file",
|
||||||
"start_s": 0.0,
|
"start_s": 0.0,
|
||||||
@@ -620,6 +625,7 @@ def stream_session(
|
|||||||
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
|
||||||
# segment streams are otherwise identical.
|
# segment streams are otherwise identical.
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"mode": "hls",
|
"mode": "hls",
|
||||||
"start_s": s.media_start_s,
|
"start_s": s.media_start_s,
|
||||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ def playlist_add_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"added": added, "item_count": total}
|
return {"ok": True, "added": added, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||||
@@ -356,7 +356,7 @@ def playlist_remove_bulk(
|
|||||||
)
|
)
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
return {"removed": removed, "item_count": total}
|
return {"ok": True, "removed": removed, "item_count": total}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/playlists/{pid}/order")
|
@router.put("/playlists/{pid}/order")
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ def watch_unlock(
|
|||||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||||
}
|
}
|
||||||
@@ -118,6 +119,7 @@ def watch_unlock(
|
|||||||
db.commit()
|
db.commit()
|
||||||
grant = linksmod.make_grant(token, link.password_version)
|
grant = linksmod.make_grant(token, link.password_version)
|
||||||
return {
|
return {
|
||||||
|
"ok": True,
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(
|
**linksmod.public_meta(
|
||||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def set_job_interval(
|
|||||||
after={"interval_minutes": applied},
|
after={"interval_minutes": applied},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"id": job_id, "interval_minutes": applied}
|
return {"ok": True, "id": job_id, "interval_minutes": applied}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/jobs/{job_id}/run")
|
@router.post("/jobs/{job_id}/run")
|
||||||
@@ -77,13 +77,13 @@ def run_job_now(
|
|||||||
raise not_found("job")
|
raise not_found("job")
|
||||||
if result == "already_running":
|
if result == "already_running":
|
||||||
raise refused(409, "job_running")
|
raise refused(409, "job_running")
|
||||||
return {"id": job_id, "started": True}
|
return {"ok": True, "id": job_id, "started": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/run-all")
|
@router.post("/run-all")
|
||||||
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
def run_all_now(user: User = Depends(admin_user)) -> dict:
|
||||||
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
"""Trigger every job that isn't already running. Returns the ids actually started."""
|
||||||
return {"started": trigger_all(actor_id=user.id)}
|
return {"ok": True, "started": trigger_all(actor_id=user.id)}
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceSettingsIn(BaseModel):
|
class MaintenanceSettingsIn(BaseModel):
|
||||||
@@ -112,7 +112,7 @@ def set_maintenance_settings(
|
|||||||
after={"revalidate_batch": new},
|
after={"revalidate_batch": new},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"revalidate_batch": new}
|
return {"ok": True, "revalidate_batch": new}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ def clear_search(
|
|||||||
find. Kept videos and organically-owned ones are left untouched."""
|
find. Kept videos and organically-owned ones are left untouched."""
|
||||||
ids = body.video_ids
|
ids = body.video_ids
|
||||||
if not ids:
|
if not ids:
|
||||||
return {"removed": 0}
|
return {"ok": True, "removed": 0}
|
||||||
db.execute(
|
db.execute(
|
||||||
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
delete(SearchFind).where(SearchFind.user_id == user.id, SearchFind.video_id.in_(ids))
|
||||||
)
|
)
|
||||||
@@ -431,4 +431,4 @@ def clear_search(
|
|||||||
)
|
)
|
||||||
).rowcount
|
).rowcount
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"removed": removed}
|
return {"ok": True, "removed": removed}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
|||||||
raise setting_refused(exc) from None
|
raise setting_refused(exc) from None
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||||
return {"saved": saved}
|
return {"ok": True, "saved": saved}
|
||||||
|
|
||||||
|
|
||||||
class SetupTestEmailIn(BaseModel):
|
class SetupTestEmailIn(BaseModel):
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ def deep_all(
|
|||||||
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
update(Subscription).where(Subscription.user_id == user.id).values(deep_requested=on)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"updated": result.rowcount, "deep_requested": on}
|
return {"ok": True, "updated": result.rowcount, "deep_requested": on}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
@@ -151,7 +151,7 @@ def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db))
|
|||||||
state.set_sync_paused(db, True)
|
state.set_sync_paused(db, True)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"paused": True}
|
return {"ok": True, "paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@router.post("/resume")
|
||||||
@@ -159,4 +159,4 @@ def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||||||
state.set_sync_paused(db, False)
|
state.set_sync_paused(db, False)
|
||||||
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"paused": False}
|
return {"ok": True, "paused": False}
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ def create_tag(
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "tag_name_taken", name=name)
|
raise refused(409, "tag_name_taken", name=name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"color": tag.color,
|
||||||
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TagUpdateIn(BaseModel):
|
class TagUpdateIn(BaseModel):
|
||||||
@@ -108,7 +114,13 @@ def update_tag(
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "tag_name_taken", name=tag.name)
|
raise refused(409, "tag_name_taken", name=tag.name)
|
||||||
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": tag.id,
|
||||||
|
"name": tag.name,
|
||||||
|
"color": tag.color,
|
||||||
|
"category": tag.category,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}")
|
@router.delete("/{tag_id}")
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
|
|||||||
|
|
||||||
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
|
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
|
||||||
|
|
||||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
|
||||||
assert user is not None, "POST /auth/register answered ok but created nothing"
|
assert user is not None, "POST /auth/register answered ok but created nothing"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
|
|||||||
|
|
||||||
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
|
r = client.post("/auth/demo", json={"email": "visitor@example.com"})
|
||||||
|
|
||||||
assert r.json() == {"authenticated": True}
|
assert r.json() == {"ok": True, "authenticated": True}
|
||||||
demo = auth_routes.get_or_create_demo_user(db)
|
demo = auth_routes.get_or_create_demo_user(db)
|
||||||
db.refresh(demo)
|
db.refresh(demo)
|
||||||
assert demo.last_login_at is None
|
assert demo.last_login_at is None
|
||||||
|
|||||||
@@ -76,5 +76,5 @@ def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_u
|
|||||||
r = as_user(user).post(
|
r = as_user(user).post(
|
||||||
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
|
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
|
||||||
)
|
)
|
||||||
assert r.json() == {"status": "watched", "position_seconds": 0}
|
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
{"item_rating_keys": ["m1", "m2", "m3"]},
|
{"item_rating_keys": ["m1", "m2", "m3"]},
|
||||||
)
|
)
|
||||||
assert added.status_code == 200, added.text
|
assert added.status_code == 200, added.text
|
||||||
assert added.json() == {"added": 3, "item_count": 3}
|
assert added.json() == {"ok": True, "added": 3, "item_count": 3}
|
||||||
|
|
||||||
removed = _request(
|
removed = _request(
|
||||||
db,
|
db,
|
||||||
@@ -448,7 +448,7 @@ class TestPlaylistAndCollectionEndpoints:
|
|||||||
{"item_rating_keys": ["m2"]},
|
{"item_rating_keys": ["m2"]},
|
||||||
)
|
)
|
||||||
assert removed.status_code == 200, removed.text
|
assert removed.status_code == 200, removed.text
|
||||||
assert removed.json() == {"removed": 1, "item_count": 2}
|
assert removed.json() == {"ok": True, "removed": 1, "item_count": 2}
|
||||||
|
|
||||||
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
detail = _get(db, user, monkeypatch, override_dep, f"/api/plex/playlists/{pid}").json()
|
||||||
assert [it["id"] for it in detail["items"]] == [
|
assert [it["id"] for it in detail["items"]] == [
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Two rows of CLAUDE.md's canonical-patterns table, held in code rather than prose.
|
||||||
|
|
||||||
|
A rule stated only in a comment drifts: this one had — 63 mutation responses without `ok`, and
|
||||||
|
twelve route helpers that committed on their own, among them an approval whose decision, activation
|
||||||
|
and token landed in three separate transactions. The debt burn of 2026-09 fixed the drift; these keep
|
||||||
|
it fixed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROUTES = Path(__file__).parent.parent / "app" / "routes"
|
||||||
|
MUTATING = {"post", "put", "patch", "delete"}
|
||||||
|
|
||||||
|
# A route helper that commits where it stands, and why the commit has to be there rather than in
|
||||||
|
# the handler. Anything else flushes and leaves the commit to its handler.
|
||||||
|
COMMITTING_HELPERS = {
|
||||||
|
"auth.py:_issue_token": "the token must be durable before the link that carries it is mailed",
|
||||||
|
"auth.py:_consume_token": "burning a single-use token is its own unit: a later failure must "
|
||||||
|
"not hand the token back",
|
||||||
|
"auth.py:_complete_link": "the tail of the Google callback route, which answers with its result",
|
||||||
|
"auth.py:_register_account": "a background task with its own session, i.e. a job wrapper",
|
||||||
|
"search.py:_ingest_candidates": "stages around YouTube calls: the stubs must survive an enrich "
|
||||||
|
"that fails",
|
||||||
|
"search.py:_classify_shorts": "the last stage of that ingest, after its probes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _modules():
|
||||||
|
for path in sorted(ROUTES.rglob("*.py")):
|
||||||
|
yield path.relative_to(ROUTES).as_posix(), ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mutation_route(fn: ast.FunctionDef) -> bool:
|
||||||
|
return any(
|
||||||
|
isinstance(d, ast.Call) and getattr(d.func, "attr", "") in MUTATING
|
||||||
|
for d in fn.decorator_list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_mutation_answers_ok():
|
||||||
|
"""`{"ok": true, …}` (or `{"deleted": id}`) for every mutation that answers with a literal —
|
||||||
|
one field a client can read the same way everywhere. A serializer's return is exempt: it
|
||||||
|
describes a resource, not an acknowledgement."""
|
||||||
|
missing = []
|
||||||
|
for name, tree in _modules():
|
||||||
|
for fn in ast.walk(tree):
|
||||||
|
if not (isinstance(fn, ast.FunctionDef) and _is_mutation_route(fn)):
|
||||||
|
continue
|
||||||
|
for node in ast.walk(fn):
|
||||||
|
if isinstance(node, ast.Return) and isinstance(node.value, ast.Dict):
|
||||||
|
keys = {k.value for k in node.value.keys if isinstance(k, ast.Constant)}
|
||||||
|
if not keys & {"ok", "deleted"}:
|
||||||
|
missing.append(f"{name}:{node.lineno} {fn.name}")
|
||||||
|
assert missing == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_helpers_flush_and_leave_the_commit_to_the_handler():
|
||||||
|
committing = set()
|
||||||
|
for name, tree in _modules():
|
||||||
|
for fn in ast.walk(tree):
|
||||||
|
if isinstance(fn, ast.FunctionDef) and fn.name.startswith("_"):
|
||||||
|
if any(
|
||||||
|
isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "commit"
|
||||||
|
for n in ast.walk(fn)
|
||||||
|
):
|
||||||
|
committing.add(f"{name}:{fn.name}")
|
||||||
|
assert committing == set(COMMITTING_HELPERS)
|
||||||
@@ -220,7 +220,7 @@ def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, clie
|
|||||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
assert r.status_code == 200 and r.json() == {"ok": True, "status": "ok"}
|
||||||
assert len(sent) == 2
|
assert len(sent) == 2
|
||||||
assert sent[1][0][0] == "twice@example.com"
|
assert sent[1][0][0] == "twice@example.com"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user