feat(music-lab): YouTube OAuth + resumable 업로드
This commit is contained in:
156
music-lab/app/pipeline/youtube.py
Normal file
156
music-lab/app/pipeline/youtube.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|||||||
88
music-lab/tests/test_youtube_upload.py
Normal file
88
music-lab/tests/test_youtube_upload.py
Normal 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"
|
||||||
Reference in New Issue
Block a user