"""Azure Blob Storage 연동 — 완성 영상·메타데이터 업로드 + 갤러리 목록 조회. SDK 없이 httpx 로 Azure Blob REST API 를 직접 호출한다(이미 의존성). BASE_URL: https://account.blob.core.windows.net/container/prefix/ → 업로드: PUT BASE_URL{blob}?{sas} → 목록: GET https://account.blob.core.windows.net/container ?restype=container&comp=list&prefix={prefix}&{sas} → 공개URL: BASE_URL{blob} (SAS 불필요, 공개 컨테이너) """ from __future__ import annotations import json import xml.etree.ElementTree as ET from email.utils import parsedate_to_datetime from pathlib import Path from urllib.parse import urlparse import httpx from app import config as cfg def _put_url(filename: str) -> str: """업로드 SAS URL — BASE_URL/{filename}?{sas_token}.""" return f"{cfg.AZURE_BLOB_BASE_URL}{filename}?{cfg.AZURE_BLOB_SAS_TOKEN}" def _list_url() -> str: """컨테이너 List Blobs SAS URL (prefix 필터 포함).""" parsed = urlparse(cfg.AZURE_BLOB_BASE_URL) # parsed.path = /container/prefix/ → parts = ["container", "prefix/"] path_parts = parsed.path.lstrip("/").split("/", 1) container = path_parts[0] prefix = path_parts[1] if len(path_parts) > 1 else "" base = f"{parsed.scheme}://{parsed.netloc}/{container}" return ( f"{base}?restype=container&comp=list" f"&prefix={prefix}&{cfg.AZURE_BLOB_SAS_TOKEN}" ) def _public_url(filename: str) -> str: """SAS 없는 공개 접근 URL.""" return f"{cfg.AZURE_BLOB_BASE_URL}{filename}" # ──────────────────────────── 업로드 ──────────────────────────── def upload_mp4(job_id: str, mp4_path: Path) -> str: """mp4 파일을 Azure 에 업로드하고 공개 URL 을 반환한다.""" filename = f"{job_id}.mp4" with open(mp4_path, "rb") as f: data = f.read() resp = httpx.put( _put_url(filename), content=data, headers={"x-ms-blob-type": "BlockBlob", "Content-Type": "video/mp4"}, timeout=300.0, ) resp.raise_for_status() return _public_url(filename) def upload_meta(job_id: str, meta: dict) -> None: """메타데이터 JSON 을 Azure 에 업로드한다(캡션·프로파일 등).""" filename = f"{job_id}.json" data = json.dumps(meta, ensure_ascii=False).encode("utf-8") resp = httpx.put( _put_url(filename), content=data, headers={"x-ms-blob-type": "BlockBlob", "Content-Type": "application/json; charset=utf-8"}, timeout=30.0, ) resp.raise_for_status() # ──────────────────────────── 목록 조회 ──────────────────────────── def list_videos() -> list[dict]: """Azure 에서 mp4 블롭 목록을 최신순으로 반환. 각 항목: {job_id, video_url(공개), created_at(Unix ts)} """ resp = httpx.get(_list_url(), timeout=30.0) resp.raise_for_status() root = ET.fromstring(resp.text) items: list[dict] = [] for blob in root.iter("Blob"): name_el = blob.find("Name") if name_el is None or not (name_el.text or "").endswith(".mp4"): continue job_id = Path(name_el.text).stem # "prefix/abc123.mp4" → "abc123" ts = 0.0 props = blob.find("Properties") if props is not None: lm = props.find("Last-Modified") if lm is not None and lm.text: try: ts = parsedate_to_datetime(lm.text).timestamp() except Exception: pass items.append({ "job_id": job_id, "video_url": _public_url(f"{job_id}.mp4"), "created_at": ts, }) items.sort(key=lambda x: x["created_at"], reverse=True) return items