feat: 완성 mp4 Azure Blob 저장(ADO2 계정 재사용) + 로컬 자동정리

- blob_client: SDK 없이 aiohttp+SAS로 PUT 업로드, 공개 URL 반환(실패 시 None)
- content_sync: 새 영상 발견 시 블롭 업로드 → Content.video에 공개 URL,
  DB 커밋 성공 후 로컬 output job 폴더 삭제(OUTPUT_DIR 하위 안전장치)
- SAS 미설정 시 기존 로컬 /videos fallback 유지
- 경로 ssulbox/ 프리픽스로 ADO2 콘텐츠와 격리
- config/.env.example: AZURE_BLOB_BASE_URL/SAS_TOKEN/PREFIX 추가(실 SAS는 .env, 미커밋)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-07-08 15:26:43 +09:00
parent 9ab618db10
commit 39483e2bfb
4 changed files with 115 additions and 2 deletions

View File

@ -30,6 +30,14 @@ YOUTUBE_CLIENT_SECRET=
# OAuth 콜백 베이스 (예: https://ssulbox.example.com) # OAuth 콜백 베이스 (예: https://ssulbox.example.com)
SNS_REDIRECT_BASE=http://localhost:8000 SNS_REDIRECT_BASE=http://localhost:8000
# ---- 미디어 저장소 (Azure Blob — 선택) ----
# 설정 시 완성 mp4를 블롭에 업로드하고 DB엔 공개 URL 저장(로컬 디스크 미사용).
# 미설정 시 로컬 backend/generator/output 을 /videos 로 서빙(개발 fallback).
# BASE_URL 은 ADO2와 동일 계정 재사용(공개 읽기 컨테이너). SAS는 write 권한 필요.
AZURE_BLOB_BASE_URL=https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original
AZURE_BLOB_SAS_TOKEN=
AZURE_BLOB_PREFIX=ssulbox
# ---- 운영 DB (선택) ---- # ---- 운영 DB (선택) ----
# 미설정 시 개발용 SQLite(backend/ssulbox.db). 운영은 MySQL 권장. # 미설정 시 개발용 SQLite(backend/ssulbox.db). 운영은 MySQL 권장.
# DATABASE_URL=mysql+aiomysql://user:pass@host:3306/ssulbox # DATABASE_URL=mysql+aiomysql://user:pass@host:3306/ssulbox

View File

@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Azure Blob Storage 업로더 (ADO2 방식 슬림 포팅).
SDK 없이 SAS 토큰을 붙여 REST PUT 한다(aiohttp). 컨테이너가 공개 읽기이므로
재생 URL엔 SAS가 없고, 업로드에만 write SAS가 필요하다. SAS 미설정 시 비활성.
blob 경로: {BASE_URL}/{PREFIX}/{owner}/{job_id}/video/{파일명}
DB(Content.video)엔 SAS 없는 공개 URL을 저장한다.
"""
import re
from pathlib import Path
import aiohttp
from .config import settings
_UPLOAD_TIMEOUT = aiohttp.ClientTimeout(total=180, connect=10)
_SANITIZE_RE = re.compile(r"[^A-Za-z0-9._-]+")
def enabled() -> bool:
return settings.azure_blob_enabled
def sanitize_filename(name: str) -> str:
"""URL·blob 안전 파일명(한글/공백/특수문자 → '_'). 확장자 유지."""
stem = Path(name).stem
ext = Path(name).suffix.lower()
stem = _SANITIZE_RE.sub("_", stem).strip("_") or "video"
return f"{stem}{ext}"
def _blob_path(owner_uuid: str | None, job_id: str, filename: str) -> str:
owner = owner_uuid or "public"
fn = sanitize_filename(filename)
return f"{settings.AZURE_BLOB_PREFIX}/{owner}/{job_id}/video/{fn}"
def public_url(owner_uuid: str | None, job_id: str, filename: str) -> str:
return f"{settings.AZURE_BLOB_BASE_URL.rstrip('/')}/{_blob_path(owner_uuid, job_id, filename)}"
async def upload_video(local_path: Path, owner_uuid: str | None, job_id: str) -> str | None:
"""mp4를 블롭에 업로드하고 공개 URL 반환. 실패/비활성 시 None."""
if not enabled() or not local_path.is_file():
return None
blob = _blob_path(owner_uuid, job_id, local_path.name)
sas = (settings.AZURE_BLOB_SAS_TOKEN or "").strip("?'\"")
upload_url = f"{settings.AZURE_BLOB_BASE_URL.rstrip('/')}/{blob}?{sas}"
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
try:
data = local_path.read_bytes()
async with aiohttp.ClientSession(timeout=_UPLOAD_TIMEOUT) as s:
async with s.put(upload_url, data=data, headers=headers) as r:
if r.status in (200, 201):
return f"{settings.AZURE_BLOB_BASE_URL.rstrip('/')}/{blob}"
body = (await r.text())[:300]
print(f"[blob] upload failed {r.status}: {body}")
return None
except Exception as e: # 네트워크/타임아웃 — fallback
print(f"[blob] upload error: {type(e).__name__}: {e}")
return None

View File

@ -76,6 +76,19 @@ class Settings(BaseSettings):
"""플랫폼별 키 설정 여부. 프론트/라우터에서 활성 판단에 사용.""" """플랫폼별 키 설정 여부. 프론트/라우터에서 활성 판단에 사용."""
return {"youtube": bool(self.YOUTUBE_CLIENT_ID and self.YOUTUBE_CLIENT_SECRET)} return {"youtube": bool(self.YOUTUBE_CLIENT_ID and self.YOUTUBE_CLIENT_SECRET)}
# 미디어 저장소 — Azure Blob(ADO2와 동일 계정 재사용). SAS 없으면 로컬 디스크 fallback.
# 컨테이너는 공개 읽기이므로 재생 URL엔 SAS 불필요, 업로드(PUT)에만 write SAS 필요.
AZURE_BLOB_BASE_URL: str = (
"https://ado2mediastoragepublic.blob.core.windows.net"
"/ado2-media-public-access/ado2-media-original"
)
AZURE_BLOB_SAS_TOKEN: str | None = None # write 권한 SAS (미설정 시 로컬 저장)
AZURE_BLOB_PREFIX: str = "ssulbox" # ADO2 콘텐츠와 경로 분리
@property
def azure_blob_enabled(self) -> bool:
return bool(self.AZURE_BLOB_BASE_URL and self.AZURE_BLOB_SAS_TOKEN)
settings = Settings() settings = Settings()

View File

@ -4,10 +4,13 @@
디스크의 완성 mp4 와 DB 를 잇는다. 시작 시 + 잡 완료 시 호출. 디스크의 완성 mp4 와 DB 를 잇는다. 시작 시 + 잡 완료 시 호출.
""" """
import re import re
import shutil
from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from . import blob_client
from .config import OUTPUT_DIR, ENGINE_SCENARIO from .config import OUTPUT_DIR, ENGINE_SCENARIO
from .database import SessionLocal from .database import SessionLocal
from .models import Content from .models import Content
@ -15,6 +18,19 @@ from .models import Content
_TITLE_RE = re.compile(r"\[제목\]\s*(.+)") _TITLE_RE = re.compile(r"\[제목\]\s*(.+)")
def _cleanup_dir(job_dir: Path) -> None:
"""블롭 업로드·DB 커밋 성공한 job 폴더의 로컬 원본 제거(디스크 절약).
안전장치: 반드시 OUTPUT_DIR 하위 경로일 때만 삭제.
"""
try:
job_dir = job_dir.resolve()
if OUTPUT_DIR.resolve() in job_dir.parents and job_dir.is_dir():
shutil.rmtree(job_dir, ignore_errors=True)
except Exception as e:
print(f"[cleanup] skip {job_dir}: {type(e).__name__}: {e}")
def _title(job_dir) -> str: def _title(job_dir) -> str:
txt = job_dir / "narration.txt" txt = job_dir / "narration.txt"
if txt.exists(): if txt.exists():
@ -40,7 +56,10 @@ def _scan() -> list[dict]:
if not mp4s: if not mp4s:
continue continue
rel = mp4s[0].relative_to(OUTPUT_DIR).as_posix() rel = mp4s[0].relative_to(OUTPUT_DIR).as_posix()
rows.append({"id": job.name, "sc": sc, "title": _title(job), "video": f"/videos/{rel}"}) rows.append({
"id": job.name, "sc": sc, "title": _title(job),
"mp4": mp4s[0], "local_url": f"/videos/{rel}",
})
return rows return rows
@ -51,16 +70,27 @@ async def sync_output(session: AsyncSession | None = None, owner_uuid: str | Non
try: try:
existing = set((await session.execute(select(Content.id))).scalars().all()) existing = set((await session.execute(select(Content.id))).scalars().all())
added = 0 added = 0
uploaded_dirs: list = [] # 블롭 업로드 성공한 job 폴더 → 커밋 후 정리
for r in _scan(): for r in _scan():
if r["id"] in existing: if r["id"] in existing:
continue continue
# Azure Blob 활성 시 mp4 업로드 → 공개 URL 저장, 실패/비활성 시 로컬 /videos/ URL
video = r["local_url"]
if blob_client.enabled():
url = await blob_client.upload_video(r["mp4"], owner_uuid, r["id"])
if url:
video = url
uploaded_dirs.append(r["mp4"].parent)
session.add(Content( session.add(Content(
id=r["id"], scenario=r["sc"], title=r["title"], video=r["video"], id=r["id"], scenario=r["sc"], title=r["title"], video=video,
owner_uuid=owner_uuid, owner_uuid=owner_uuid,
)) ))
added += 1 added += 1
if added: if added:
await session.commit() await session.commit()
# 커밋 성공 후에만 로컬 원본 삭제(블롭이 진실의 원천). 실패분은 /videos fallback 위해 유지.
for d in uploaded_dirs:
_cleanup_dir(d)
return added return added
finally: finally:
if own: if own: