# -*- coding: utf-8 -*- """SNS 연동 라우터 — 유튜브(Google OAuth + YouTube Data API v3) 실연동. - 연결: /youtube/connect → Google 동의 URL → 콜백에서 토큰 저장(채널명 포함) - 업로드: 완성 mp4 를 연동 채널에 resumable 업로드 (castad app/social 패턴 이식) """ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import RedirectResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from .. import youtube_client from ..config import OUTPUT_DIR, settings from ..database import get_session from ..deps import get_current_user from ..models import Content, SocialAccount, User from ..security import create_access_token, decode_token router = APIRouter(prefix="/api/social", tags=["Social"]) # Google OAuth 앱에 등록된 콜백 경로(prefix 밖)를 그대로 받기 위한 보조 라우터. compat_router = APIRouter(tags=["Social"]) SUPPORTED = {"youtube"} @router.get("/config") async def social_config(): """플랫폼별 연동 활성(키 설정) 여부. 프론트가 '곧 지원' 배지 판단에 사용.""" return settings.social_enabled @router.get("/accounts") async def list_accounts(user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)): rows = ( await session.execute( select(SocialAccount).where(SocialAccount.user_uuid == user.user_uuid, SocialAccount.is_active == True) # noqa: E712 ) ).scalars().all() return [{"id": a.id, "platform": a.platform, "account_name": a.account_name} for a in rows] @router.get("/youtube/connect") async def youtube_connect(user: User = Depends(get_current_user)): """Google OAuth 동의 URL 반환. state 에 로그인 사용자 토큰을 실어 콜백에서 식별.""" state = create_access_token(user.user_uuid) # 짧은 수명 서명 토큰을 state 로 사용 return {"auth_url": youtube_client.get_authorization_url(state)} @compat_router.get("/social/oauth/youtube/callback") async def youtube_callback(code: str, state: str, session: AsyncSession = Depends(get_session)): """Google 콜백: state→사용자 식별, 코드→토큰 교환, 채널명 조회 후 계정 upsert.""" payload = decode_token(state) if not payload or not payload.get("sub"): raise HTTPException(400, "유효하지 않은 state") user_uuid = payload["sub"] tokens = await youtube_client.exchange_code(code) channel = await youtube_client.get_channel_info(tokens["access_token"]) acc = ( await session.execute( select(SocialAccount).where( SocialAccount.user_uuid == user_uuid, SocialAccount.platform == "youtube" ) ) ).scalar_one_or_none() if acc is None: acc = SocialAccount(user_uuid=user_uuid, platform="youtube") session.add(acc) acc.account_name = channel.get("title") or "내 채널" acc.access_token = tokens.get("access_token") if tokens.get("refresh_token"): # Google 은 재동의 때만 refresh 재발급 acc.refresh_token = tokens["refresh_token"] acc.is_active = True await session.commit() return RedirectResponse(url=f"{settings.FRONTEND_URL}/?tab=channel&connected=youtube", status_code=302) @router.delete("/{platform}/disconnect") async def disconnect(platform: str, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)): acc = ( await session.execute( select(SocialAccount).where( SocialAccount.user_uuid == user.user_uuid, SocialAccount.platform == platform ) ) ).scalar_one_or_none() if acc is None: raise HTTPException(404, "연동된 계정이 없습니다.") acc.is_active = False await session.commit() return {"ok": True} def _video_file_path(content: Content): """Content.video(/videos/...) → 디스크 실제 경로(OUTPUT_DIR 기준).""" if not content.video: return None rel = content.video.removeprefix("/videos/") return OUTPUT_DIR / rel @router.post("/upload/{platform}/{content_id}") async def upload(platform: str, content_id: str, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session)): if platform != "youtube": raise HTTPException(400, f"지원하지 않는 플랫폼: {platform}") content = await session.get(Content, content_id) if content is None or not content.video: raise HTTPException(404, "업로드할 영상을 찾을 수 없습니다.") acc = ( await session.execute( select(SocialAccount).where( SocialAccount.user_uuid == user.user_uuid, SocialAccount.platform == "youtube", SocialAccount.is_active == True, # noqa: E712 ) ) ).scalar_one_or_none() if acc is None: raise HTTPException(400, "유튜브 계정을 먼저 연동해주세요.") path = _video_file_path(content) if path is None or not path.exists(): raise HTTPException(404, "영상 파일을 찾을 수 없습니다.") access_token = acc.access_token desc = f"{content.title}\n\n#썰박스 로 만든 병맛 역사 홍보 쇼츠" try: result = await youtube_client.upload_video(str(path), access_token, content.title, desc) except HTTPException as e: # 401 이면 refresh 로 재시도 if e.status_code in (401, 403) and acc.refresh_token: access_token = await youtube_client.refresh_access_token(acc.refresh_token) acc.access_token = access_token await session.commit() result = await youtube_client.upload_video(str(path), access_token, content.title, desc) else: raise return {"ok": True, "platform": "youtube", **result}