feat(m5b): optional YouTube write scope via incremental OAuth

Default login now requests read-only (youtube.readonly); write (unsubscribe,
later playlist export) is an explicit opt-in.

- auth.py: split READ_SCOPES / WRITE_SCOPES; new GET /auth/upgrade (incremental
  consent, prompt=consent); has_write_scope() helper
- /api/me exposes can_write
- youtube/client.py: delete_subscription (50 units, OAuth-only)
- DELETE /api/channels/{id}/subscription, gated on write scope (403 otherwise)
- UI: Settings - Account 'Playlist editing & YouTube export' enable button;
  per-channel 'Unsubscribe on YouTube' (with confirm) shown only when can_write

Browser-facing; develop/test locally until the public HTTPS login lands. Needs a
one-time Console step: add youtube.readonly to the OAuth consent screen scopes.
This commit is contained in:
npeter83 2026-06-11 23:27:11 +02:00
parent 02481b3283
commit d02b540c60
8 changed files with 152 additions and 11 deletions

View file

@ -5,9 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from app.auth import current_user
from app.auth import current_user, has_write_scope
from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/channels", tags=["channels"])
@ -101,6 +102,35 @@ def update_channel(
}
@router.delete("/{channel_id}/subscription")
def unsubscribe(
channel_id: str,
user: User = Depends(current_user),
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.",
)
sub = _user_subscription(db, user, channel_id)
if not sub.yt_subscription_id:
raise HTTPException(
status_code=400,
detail="No YouTube subscription id on record — sync subscriptions first.",
)
try:
with YouTubeClient(db, user) as yt:
yt.delete_subscription(sub.yt_subscription_id)
except YouTubeError as exc:
raise HTTPException(status_code=502, detail=f"YouTube unsubscribe failed: {exc}")
db.delete(sub)
db.commit()
return {"unsubscribed": channel_id}
@router.post("/{channel_id}/tags")
def attach_tag(
channel_id: str,