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)
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue