# -*- 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