ssulbox/backend/app/routers/sns.py

143 lines
5.8 KiB
Python

# -*- 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}