From 39483e2bfbea54d367233741a381ae8dec9676d9 Mon Sep 17 00:00:00 2001 From: jwkim Date: Wed, 8 Jul 2026 15:26:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=84=EC=84=B1=20mp4=20Azure=20Blob?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5(ADO2=20=EA=B3=84=EC=A0=95=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)=20+=20=EB=A1=9C=EC=BB=AC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/.env.example | 8 +++++ backend/app/blob_client.py | 62 +++++++++++++++++++++++++++++++++++++ backend/app/config.py | 13 ++++++++ backend/app/content_sync.py | 34 ++++++++++++++++++-- 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 backend/app/blob_client.py diff --git a/backend/.env.example b/backend/.env.example index cf388b7..48783e1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/blob_client.py b/backend/app/blob_client.py new file mode 100644 index 0000000..3421fda --- /dev/null +++ b/backend/app/blob_client.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index cdb838c..d16a1bb 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/content_sync.py b/backend/app/content_sync.py index 8a0ea88..7326026 100644 --- a/backend/app/content_sync.py +++ b/backend/app/content_sync.py @@ -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: