Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck - Google OAuth (single allowed account), encrypted refresh token storage - Subscriptions sync with pagination, uploads playlist batch fetch - Categories CRUD, many-to-many channel assignment, category filtering - Video sync (playlistItems + videos.list batching), cached feed with cursor pagination, background scheduler (APScheduler) - Video detail page with YouTube embed player - SPA fallback routing, optimistic UI updates, client-side query caching 40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes, cursor pagination, and category filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
0ed20bb838
90 changed files with 8545 additions and 0 deletions
142
backend/app/services/google_oauth.py
Normal file
142
backend/app/services/google_oauth.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from google.auth.transport.requests import Request as GoogleAuthRequest
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.crypto import decrypt_token, encrypt_token
|
||||
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
|
||||
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
|
||||
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
||||
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
|
||||
|
||||
|
||||
class OAuthNotConnected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _client_config() -> dict:
|
||||
return {
|
||||
"web": {
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"auth_uri": AUTH_URI,
|
||||
"token_uri": TOKEN_URI,
|
||||
"redirect_uris": [settings.google_redirect_uri],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _build_flow(state: str | None = None) -> Flow:
|
||||
return Flow.from_client_config(
|
||||
_client_config(),
|
||||
scopes=SCOPES,
|
||||
state=state,
|
||||
redirect_uri=settings.google_redirect_uri,
|
||||
)
|
||||
|
||||
|
||||
def build_authorization_url() -> tuple[str, str]:
|
||||
flow = _build_flow()
|
||||
auth_url, state = flow.authorization_url(
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
include_granted_scopes="true",
|
||||
)
|
||||
return auth_url, state
|
||||
|
||||
|
||||
def exchange_code(code: str, state: str) -> Credentials:
|
||||
flow = _build_flow(state=state)
|
||||
flow.fetch_token(code=code)
|
||||
return flow.credentials
|
||||
|
||||
|
||||
def fetch_userinfo(access_token: str) -> dict:
|
||||
response = httpx.get(
|
||||
USERINFO_URI,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=settings.metube_request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def revoke_token(token: str) -> None:
|
||||
try:
|
||||
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
|
||||
except Exception:
|
||||
logger.warning("Failed to revoke Google token", exc_info=True)
|
||||
|
||||
|
||||
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
|
||||
encrypted = encrypt_token(credentials.refresh_token)
|
||||
expires_at = credentials.expiry
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is None:
|
||||
row = OAuthCredentials(id=SINGLETON_ID, google_email=google_email, encrypted_refresh_token=encrypted)
|
||||
db.add(row)
|
||||
else:
|
||||
row.google_email = google_email
|
||||
row.encrypted_refresh_token = encrypted
|
||||
row.access_token_expires_at = expires_at
|
||||
db.commit()
|
||||
|
||||
|
||||
def clear_credentials(db: Session) -> None:
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
def is_connected(db: Session) -> bool:
|
||||
return db.get(OAuthCredentials, SINGLETON_ID) is not None
|
||||
|
||||
|
||||
def get_connected_email(db: Session) -> str | None:
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
return row.google_email if row else None
|
||||
|
||||
|
||||
def get_credentials(db: Session) -> Credentials:
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is None:
|
||||
raise OAuthNotConnected("Google account is not connected")
|
||||
|
||||
refresh_token = decrypt_token(row.encrypted_refresh_token)
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=TOKEN_URI,
|
||||
client_id=settings.google_client_id,
|
||||
client_secret=settings.google_client_secret,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
credentials.refresh(GoogleAuthRequest())
|
||||
|
||||
expires_at = credentials.expiry
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
row.access_token_expires_at = expires_at
|
||||
db.commit()
|
||||
|
||||
return credentials
|
||||
Loading…
Add table
Add a link
Reference in a new issue