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>
main
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)
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 (선택) ----
# 미설정 시 개발용 SQLite(backend/ssulbox.db). 운영은 MySQL 권장.
# 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)}
# 미디어 저장소 — 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()

View File

@ -4,10 +4,13 @@
디스크의 완성 mp4 DB 잇는다. 시작 + 완료 호출.
"""
import re
import shutil
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from . import blob_client
from .config import OUTPUT_DIR, ENGINE_SCENARIO
from .database import SessionLocal
from .models import Content
@ -15,6 +18,19 @@ from .models import Content
_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:
txt = job_dir / "narration.txt"
if txt.exists():
@ -40,7 +56,10 @@ def _scan() -> list[dict]:
if not mp4s:
continue
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
@ -51,16 +70,27 @@ async def sync_output(session: AsyncSession | None = None, owner_uuid: str | Non
try:
existing = set((await session.execute(select(Content.id))).scalars().all())
added = 0
uploaded_dirs: list = [] # 블롭 업로드 성공한 job 폴더 → 커밋 후 정리
for r in _scan():
if r["id"] in existing:
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(
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,
))
added += 1
if added:
await session.commit()
# 커밋 성공 후에만 로컬 원본 삭제(블롭이 진실의 원천). 실패분은 /videos fallback 위해 유지.
for d in uploaded_dirs:
_cleanup_dir(d)
return added
finally:
if own: