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
parent
9ab618db10
commit
39483e2bfb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue