143 lines
5.8 KiB
Python
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}
|