feat(music-lab): YouTube OAuth + resumable 업로드

This commit is contained in:
2026-05-07 17:05:12 +09:00
parent ad1c721ba8
commit 4755e34c14
3 changed files with 247 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
"""YouTube OAuth flow + resumable 업로드."""
import os
import logging
from urllib.parse import urlencode
import httpx
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
from googleapiclient.errors import HttpError
from app import db
logger = logging.getLogger("music-lab.youtube")
SCOPES = ["https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly"]
class NotAuthenticatedError(Exception):
pass
class QuotaExceededError(Exception):
pass
def _client_id() -> str:
return os.getenv("YOUTUBE_OAUTH_CLIENT_ID", "")
def _client_secret() -> str:
return os.getenv("YOUTUBE_OAUTH_CLIENT_SECRET", "")
def _redirect_uri() -> str:
return os.getenv("YOUTUBE_OAUTH_REDIRECT_URI", "")
def get_auth_url() -> str:
cid = _client_id()
redirect = _redirect_uri()
if not cid or not redirect:
raise RuntimeError("OAuth 환경변수 미설정")
params = {
"client_id": cid,
"redirect_uri": redirect,
"response_type": "code",
"scope": " ".join(SCOPES),
"access_type": "offline",
"prompt": "consent",
}
return "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
async def exchange_code(code: str) -> dict:
"""code → refresh_token + access_token + 채널 정보 → DB 저장."""
async with httpx.AsyncClient(timeout=30) as client:
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": _client_id(),
"client_secret": _client_secret(),
"redirect_uri": _redirect_uri(),
"grant_type": "authorization_code",
},
)
token_resp.raise_for_status()
tok = token_resp.json()
access = tok["access_token"]
refresh = tok["refresh_token"]
expires_at = _expiry_from_seconds(tok["expires_in"])
creds = _creds(access=access, refresh=refresh)
yt = _build_youtube_client(creds)
ch = yt.channels().list(part="snippet", mine=True).execute()
item = ch["items"][0]
db.upsert_oauth_token(
channel_id=item["id"],
channel_title=item["snippet"]["title"],
avatar_url=item["snippet"]["thumbnails"]["default"]["url"],
refresh_token=refresh, access_token=access, expires_at=expires_at,
)
return {"channel_id": item["id"], "channel_title": item["snippet"]["title"]}
def get_status() -> dict | None:
tok = db.get_oauth_token()
if not tok:
return None
return {
"channel_id": tok["channel_id"],
"channel_title": tok["channel_title"],
"avatar_url": tok["avatar_url"],
}
def disconnect() -> None:
db.delete_oauth_token()
def upload_video(*, video_path: str, thumbnail_path: str | None,
metadata: dict, privacy: str) -> dict:
tok = db.get_oauth_token()
if not tok:
raise NotAuthenticatedError("YouTube 인증 없음")
creds = _creds(access=tok["access_token"], refresh=tok["refresh_token"])
yt = _build_youtube_client(creds)
body = {
"snippet": {
"title": metadata["title"],
"description": metadata["description"],
"tags": metadata.get("tags", []),
"categoryId": str(metadata.get("category_id", 10)),
},
"status": {"privacyStatus": privacy, "selfDeclaredMadeForKids": False},
}
media = MediaFileUpload(video_path, chunksize=4 * 1024 * 1024, resumable=True, mimetype="video/mp4")
req = yt.videos().insert(part="snippet,status", body=body, media_body=media)
try:
response = None
while response is None:
status, response = req.next_chunk()
video_id = response["id"]
except HttpError as e:
if b"quotaExceeded" in (e.content or b""):
raise QuotaExceededError(str(e))
raise
if thumbnail_path:
try:
yt.thumbnails().set(videoId=video_id, media_body=thumbnail_path).execute()
except HttpError as e:
logger.warning("썸네일 업로드 실패: %s", e)
return {"video_id": video_id}
def _build_youtube_client(creds): # patch 포인트
return build("youtube", "v3", credentials=creds, cache_discovery=False)
def _creds(access: str, refresh: str) -> Credentials:
return Credentials(
token=access, refresh_token=refresh,
token_uri="https://oauth2.googleapis.com/token",
client_id=_client_id(), client_secret=_client_secret(), scopes=SCOPES,
)
def _expiry_from_seconds(secs: int) -> str:
from datetime import datetime, timedelta
return (datetime.utcnow() + timedelta(seconds=secs)).isoformat(timespec="seconds")

View File

@@ -11,3 +11,6 @@ pytest-asyncio>=0.21
httpx>=0.27.0 httpx>=0.27.0
respx>=0.21 respx>=0.21
freezegun>=1.4 freezegun>=1.4
google-api-python-client>=2.100
google-auth-oauthlib>=1.2
google-auth-httplib2>=0.2

View File

@@ -0,0 +1,88 @@
import pytest
from unittest.mock import patch, MagicMock
from app.pipeline import youtube
@pytest.fixture
def fresh_db(monkeypatch, tmp_path):
from app import db
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db"))
db.init_db()
return db
def _setup_token(db_module):
db_module.upsert_oauth_token(
channel_id="UC1", channel_title="t", avatar_url=None,
refresh_token="r1", access_token="a1", expires_at="2099-01-01T00:00:00",
)
@patch("app.pipeline.youtube._build_youtube_client")
def test_upload_succeeds_after_resumable(mock_client, fresh_db, tmp_path, monkeypatch):
monkeypatch.setenv("YOUTUBE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "sec")
_setup_token(fresh_db)
yt = MagicMock()
insert = MagicMock()
# next_chunk: first call returns (None, None), second returns (None, response with id)
insert.next_chunk.side_effect = [(None, None), (None, {"id": "VID123"})]
yt.videos().insert.return_value = insert
mock_client.return_value = yt
video_path = tmp_path / "v.mp4"
video_path.write_bytes(b"\x00" * 100)
out = youtube.upload_video(
video_path=str(video_path),
thumbnail_path=None,
metadata={"title": "T", "description": "D", "tags": ["x"], "category_id": 10},
privacy="private",
)
assert out["video_id"] == "VID123"
def test_upload_no_token_raises(fresh_db, tmp_path):
video_path = tmp_path / "v.mp4"
video_path.write_bytes(b"\x00")
with pytest.raises(youtube.NotAuthenticatedError):
youtube.upload_video(
video_path=str(video_path), thumbnail_path=None,
metadata={"title":"T","description":"D","tags":[],"category_id":10},
privacy="private",
)
@patch("app.pipeline.youtube._build_youtube_client")
def test_upload_quota_exceeded_marks_quota(mock_client, fresh_db, tmp_path, monkeypatch):
from googleapiclient.errors import HttpError
monkeypatch.setenv("YOUTUBE_OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "sec")
_setup_token(fresh_db)
yt = MagicMock()
err = HttpError(MagicMock(status=403), b'{"error":{"errors":[{"reason":"quotaExceeded"}]}}')
insert_call = MagicMock()
insert_call.next_chunk.side_effect = err
yt.videos().insert.return_value = insert_call
mock_client.return_value = yt
video_path = tmp_path / "v.mp4"
video_path.write_bytes(b"\x00")
with pytest.raises(youtube.QuotaExceededError):
youtube.upload_video(
video_path=str(video_path), thumbnail_path=None,
metadata={"title":"T","description":"D","tags":[],"category_id":10},
privacy="private",
)
def test_get_status_returns_none_when_not_connected(fresh_db):
assert youtube.get_status() is None
def test_get_status_returns_channel_info(fresh_db):
_setup_token(fresh_db)
s = youtube.get_status()
assert s["channel_id"] == "UC1"
assert s["channel_title"] == "t"