refactor(api): shared serializer + ownership + write-scope helpers (R6 S1a)

Establish the R6 API-contract infra before the S1b payload:dict migration:
- iso(dt) in utils.py replaces the ~16 hand-rolled `x.isoformat() if x else None`
  serializer idioms across the routes.
- get_or_404 / owned_or_404 in routes/_common.py — one 404 + existence-leak policy;
  plex's _own_playlist_or_404 and saved_views' _own_view now delegate to it.
- require_write_scope dependency in auth.py collapses the inline has_write_scope→403
  checks (channels subscribe/unsubscribe, playlists push/push-plan) that had drifted
  into three different messages into one; the conditional delete_playlist guard stays
  inline (its message was already canonical).

No response-shape change (iso == isoformat for a present value). Gate: ruff clean,
pytest 163 green (+ test_utils for iso).
This commit is contained in:
2026-07-26 04:17:08 +02:00
parent a711b51224
commit c5533b0ac5
14 changed files with 132 additions and 60 deletions
+12 -20
View File
@@ -9,8 +9,9 @@ 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.utils import iso
from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel,
@@ -67,7 +68,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 +233,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,
@@ -454,19 +455,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 +520,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(