737 lines
28 KiB
Python
737 lines
28 KiB
Python
"""
|
|
Azure Blob Storage 업로드 유틸리티
|
|
|
|
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
|
|
파일 경로 또는 바이트 데이터를 직접 업로드할 수 있습니다.
|
|
|
|
URL 경로 형식:
|
|
- 음악: {BASE_URL}/{user_uuid}/{task_id}/song/{파일명}
|
|
- 영상: {BASE_URL}/{user_uuid}/{task_id}/video/{파일명}
|
|
- 이미지: {BASE_URL}/{user_uuid}/{task_id}/image/{파일명}
|
|
|
|
사용 예시:
|
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
|
|
# 파일 경로로 업로드
|
|
success = await uploader.upload_music(file_path="my_song.mp3")
|
|
success = await uploader.upload_video(file_path="my_video.mp4")
|
|
success = await uploader.upload_image(file_path="my_image.png")
|
|
|
|
# 바이트 데이터로 직접 업로드 (media 저장 없이)
|
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
|
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
|
|
|
|
print(uploader.public_url) # 마지막 업로드의 공개 URL
|
|
|
|
성능 최적화:
|
|
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀 재사용
|
|
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
import re
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from urllib.parse import urlencode
|
|
|
|
import aiofiles
|
|
import httpx
|
|
from fastapi import UploadFile
|
|
|
|
from app.utils.logger import get_logger
|
|
from config import azure_blob_settings
|
|
|
|
# 로거 설정
|
|
logger = get_logger("blob")
|
|
|
|
|
|
class BlobUploadTooLargeError(ValueError):
|
|
"""스트리밍 중 파일 크기 상한을 초과했을 때 발생합니다."""
|
|
|
|
def __init__(self, max_size_bytes: int):
|
|
self.max_size_bytes = max_size_bytes
|
|
super().__init__(f"업로드 파일은 {max_size_bytes} bytes를 초과할 수 없습니다.")
|
|
|
|
|
|
# =============================================================================
|
|
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
|
# =============================================================================
|
|
|
|
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
|
|
_shared_blob_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
async def get_shared_blob_client() -> httpx.AsyncClient:
|
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
|
global _shared_blob_client
|
|
if _shared_blob_client is None or _shared_blob_client.is_closed:
|
|
logger.info("[AzureBlobUploader] Creating shared HTTP client...")
|
|
_shared_blob_client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(180.0, connect=10.0),
|
|
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
|
)
|
|
logger.info("[AzureBlobUploader] Shared HTTP client created - "
|
|
"max_connections: 20, max_keepalive: 10")
|
|
return _shared_blob_client
|
|
|
|
|
|
async def close_shared_blob_client() -> None:
|
|
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
|
|
global _shared_blob_client
|
|
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
|
|
await _shared_blob_client.aclose()
|
|
_shared_blob_client = None
|
|
logger.info("[AzureBlobUploader] Shared HTTP client closed")
|
|
|
|
|
|
class AzureBlobUploader:
|
|
"""Azure Blob Storage 업로드 클래스
|
|
|
|
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
|
|
URL 형식: {BASE_URL}/{user_uuid}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
|
|
|
|
카테고리별 경로:
|
|
- 음악: {user_uuid}/{task_id}/song/{file_name}
|
|
- 영상: {user_uuid}/{task_id}/video/{file_name}
|
|
- 이미지: {user_uuid}/{task_id}/image/{file_name}
|
|
|
|
Attributes:
|
|
user_uuid: 사용자 고유 식별자 (UUID)
|
|
task_id: 작업 고유 식별자
|
|
"""
|
|
|
|
# Content-Type 매핑
|
|
IMAGE_CONTENT_TYPES = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".bmp": "image/bmp",
|
|
".heic": "image/heic",
|
|
".heif": "image/heif",
|
|
}
|
|
|
|
def __init__(self, user_uuid: str, task_id: str):
|
|
"""AzureBlobUploader 초기화
|
|
|
|
Args:
|
|
user_uuid: 사용자 고유 식별자 (UUID)
|
|
task_id: 작업 고유 식별자
|
|
"""
|
|
self._user_uuid = user_uuid
|
|
self._task_id = task_id
|
|
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
|
|
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
|
self._last_public_url: str = ""
|
|
|
|
@property
|
|
def user_uuid(self) -> str:
|
|
"""사용자 고유 식별자 (UUID)"""
|
|
return self._user_uuid
|
|
|
|
@property
|
|
def task_id(self) -> str:
|
|
"""작업 고유 식별자"""
|
|
return self._task_id
|
|
|
|
@property
|
|
def public_url(self) -> str:
|
|
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
|
|
return self._last_public_url
|
|
|
|
def _sanitize_filename(self, file_name: str) -> str:
|
|
"""파일명에서 공백/특수문자 제거, 한글/영문/숫자만 허용
|
|
|
|
Args:
|
|
file_name: 원본 파일명
|
|
|
|
Returns:
|
|
str: 정리된 파일명 (한글, 영문, 숫자만 포함)
|
|
|
|
Example:
|
|
>>> self._sanitize_filename("my file (1).mp4")
|
|
'myfile1.mp4'
|
|
>>> self._sanitize_filename("테스트 파일!@#.png")
|
|
'테스트파일.png'
|
|
"""
|
|
stem = Path(file_name).stem
|
|
suffix = Path(file_name).suffix
|
|
|
|
# 한글(가-힣), 영문(a-zA-Z), 숫자(0-9)만 남기고 제거
|
|
sanitized = re.sub(r'[^가-힣a-zA-Z0-9]', '', stem)
|
|
|
|
# 빈 문자열이면 기본값 사용
|
|
if not sanitized:
|
|
sanitized = "file"
|
|
|
|
return f"{sanitized}{suffix}"
|
|
|
|
def _build_upload_url(self, category: str, file_name: str) -> str:
|
|
"""업로드 URL 생성 (SAS 토큰 포함)"""
|
|
# SAS 토큰 앞뒤의 ?, ', " 제거
|
|
sas_token = self._sas_token.strip("?'\"")
|
|
return (
|
|
f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}?{sas_token}"
|
|
)
|
|
|
|
def _build_public_url(self, category: str, file_name: str) -> str:
|
|
"""공개 URL 생성 (SAS 토큰 제외)"""
|
|
return f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}"
|
|
|
|
async def _upload_bytes(
|
|
self,
|
|
file_content: bytes,
|
|
upload_url: str,
|
|
headers: dict,
|
|
timeout: float,
|
|
log_prefix: str,
|
|
) -> bool:
|
|
"""바이트 데이터를 업로드하는 공통 내부 메서드
|
|
|
|
Args:
|
|
file_content: 업로드할 바이트 데이터
|
|
upload_url: 업로드 URL
|
|
headers: HTTP 헤더
|
|
timeout: 요청 타임아웃 (초)
|
|
log_prefix: 로그 접두사
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
"""
|
|
size = len(file_content)
|
|
start_time = time.perf_counter()
|
|
|
|
try:
|
|
logger.info(f"[{log_prefix}] Starting upload")
|
|
logger.debug(f"[{log_prefix}] Getting shared client...")
|
|
|
|
client = await get_shared_blob_client()
|
|
client_time = time.perf_counter()
|
|
elapsed_ms = (client_time - start_time) * 1000
|
|
logger.debug(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
|
|
|
|
logger.debug(f"[{log_prefix}] Starting upload... "
|
|
f"(size: {size} bytes, timeout: {timeout}s)")
|
|
|
|
request_headers = {
|
|
**headers,
|
|
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
|
|
}
|
|
response = await asyncio.wait_for(
|
|
client.put(upload_url, content=file_content, headers=request_headers),
|
|
timeout=timeout,
|
|
)
|
|
upload_time = time.perf_counter()
|
|
duration_ms = (upload_time - start_time) * 1000
|
|
|
|
if response.status_code in [200, 201]:
|
|
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
|
f"Duration: {duration_ms:.1f}ms")
|
|
logger.debug(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
|
return True
|
|
|
|
# 업로드 실패
|
|
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
|
f"Duration: {duration_ms:.1f}ms")
|
|
logger.error(f"[{log_prefix}] Response: {response.text[:500]}")
|
|
return False
|
|
|
|
except asyncio.TimeoutError:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
|
return False
|
|
|
|
except httpx.ConnectError as e:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
|
f"{type(e).__name__}: {e}")
|
|
return False
|
|
|
|
except httpx.ReadError as e:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
|
f"{type(e).__name__}: {e}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
|
f"{type(e).__name__}: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def _append_query(upload_url: str, **params: str) -> str:
|
|
"""SAS 쿼리를 유지하며 Azure REST API 쿼리를 추가합니다."""
|
|
separator = "&" if "?" in upload_url else "?"
|
|
return f"{upload_url}{separator}{urlencode(params)}"
|
|
|
|
async def _delete_upload_url(self, upload_url: str, log_prefix: str) -> bool:
|
|
"""실패한 업로드의 커밋/미커밋 Blob을 정리합니다."""
|
|
try:
|
|
client = await get_shared_blob_client()
|
|
response = await client.delete(
|
|
upload_url,
|
|
headers={"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION},
|
|
)
|
|
if response.status_code in {202, 404}:
|
|
return True
|
|
logger.warning(
|
|
f"[{log_prefix}] Blob cleanup failed - Status: "
|
|
f"{response.status_code}, Response: {response.text[:500]}"
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
f"[{log_prefix}] Blob cleanup error - {type(exc).__name__}: {exc}"
|
|
)
|
|
return False
|
|
|
|
async def _upload_stream(
|
|
self,
|
|
chunks: AsyncIterator[bytes],
|
|
upload_url: str,
|
|
content_type: str,
|
|
timeout: float,
|
|
log_prefix: str,
|
|
*,
|
|
max_size_bytes: int | None = None,
|
|
expected_size_bytes: int | None = None,
|
|
cleanup_blob_on_failure: bool = False,
|
|
) -> bool:
|
|
"""Azure Block Blob API로 비동기 청크 스트림을 업로드합니다.
|
|
|
|
각 블록만 메모리에 유지하므로 파일 전체 크기와 무관하게 메모리 사용량이
|
|
일정합니다. 커밋 전 오류가 발생하면 업로드 대상 Blob 삭제를 시도합니다.
|
|
"""
|
|
block_ids: list[str] = []
|
|
block_id_nonce = os.urandom(16)
|
|
uploaded_size = 0
|
|
start_time = time.perf_counter()
|
|
|
|
async def cleanup_failed_stream() -> None:
|
|
# 기존 deterministic key는 미커밋 블록만 TTL 정리되게 두어 정상 Blob을 보존합니다.
|
|
if cleanup_blob_on_failure:
|
|
await self._delete_upload_url(upload_url, log_prefix)
|
|
|
|
try:
|
|
client = await get_shared_blob_client()
|
|
async with asyncio.timeout(timeout):
|
|
async for chunk in chunks:
|
|
if not chunk:
|
|
continue
|
|
|
|
uploaded_size += len(chunk)
|
|
if max_size_bytes is not None and uploaded_size > max_size_bytes:
|
|
raise BlobUploadTooLargeError(max_size_bytes)
|
|
|
|
raw_block_id = block_id_nonce + len(block_ids).to_bytes(4, "big")
|
|
block_id = base64.b64encode(raw_block_id).decode("ascii")
|
|
block_url = self._append_query(
|
|
upload_url,
|
|
comp="block",
|
|
blockid=block_id,
|
|
)
|
|
response = await client.put(
|
|
block_url,
|
|
content=chunk,
|
|
headers={
|
|
"Content-Type": "application/octet-stream",
|
|
"x-ms-version": (
|
|
azure_blob_settings.AZURE_BLOB_API_VERSION
|
|
),
|
|
},
|
|
)
|
|
if response.status_code != 201:
|
|
logger.error(
|
|
f"[{log_prefix}] Block upload failed - Status: "
|
|
f"{response.status_code}, Response: {response.text[:500]}"
|
|
)
|
|
await cleanup_failed_stream()
|
|
return False
|
|
block_ids.append(block_id)
|
|
|
|
if uploaded_size == 0:
|
|
logger.warning(f"[{log_prefix}] Empty upload stream")
|
|
await cleanup_failed_stream()
|
|
return False
|
|
|
|
if (
|
|
expected_size_bytes is not None
|
|
and uploaded_size != expected_size_bytes
|
|
):
|
|
logger.error(
|
|
f"[{log_prefix}] Stream size changed - expected: "
|
|
f"{expected_size_bytes}, actual: {uploaded_size}"
|
|
)
|
|
await cleanup_failed_stream()
|
|
return False
|
|
|
|
block_list = "".join(
|
|
f"<Latest>{block_id}</Latest>" for block_id in block_ids
|
|
)
|
|
commit_body = (
|
|
f'<?xml version="1.0" encoding="utf-8"?>'
|
|
f"<BlockList>{block_list}</BlockList>"
|
|
).encode("utf-8")
|
|
commit_url = self._append_query(upload_url, comp="blocklist")
|
|
response = await client.put(
|
|
commit_url,
|
|
content=commit_body,
|
|
headers={
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"x-ms-blob-content-type": content_type,
|
|
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
|
|
},
|
|
)
|
|
if response.status_code not in {200, 201}:
|
|
logger.error(
|
|
f"[{log_prefix}] Block list commit failed - Status: "
|
|
f"{response.status_code}, Response: {response.text[:500]}"
|
|
)
|
|
await cleanup_failed_stream()
|
|
return False
|
|
|
|
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
logger.info(
|
|
f"[{log_prefix}] SUCCESS - blocks: {len(block_ids)}, "
|
|
f"size: {uploaded_size} bytes, Duration: {duration_ms:.1f}ms"
|
|
)
|
|
return True
|
|
|
|
except BlobUploadTooLargeError:
|
|
await cleanup_failed_stream()
|
|
raise
|
|
except asyncio.CancelledError:
|
|
# 클라이언트 연결 종료 중에도 가능한 범위에서 staged block을 정리합니다.
|
|
await asyncio.shield(cleanup_failed_stream())
|
|
raise
|
|
except TimeoutError:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
|
except httpx.HTTPError as exc:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(
|
|
f"[{log_prefix}] HTTP_ERROR after {elapsed:.1f}s - "
|
|
f"{type(exc).__name__}: {exc}"
|
|
)
|
|
except Exception as exc:
|
|
elapsed = time.perf_counter() - start_time
|
|
logger.error(
|
|
f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
|
f"{type(exc).__name__}: {exc}"
|
|
)
|
|
|
|
await cleanup_failed_stream()
|
|
return False
|
|
|
|
async def _upload_file(
|
|
self,
|
|
file_path: str,
|
|
category: str,
|
|
content_type: str,
|
|
timeout: float,
|
|
log_prefix: str,
|
|
) -> bool:
|
|
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
|
|
|
|
Args:
|
|
file_path: 업로드할 파일 경로
|
|
category: 카테고리 (song, video, image)
|
|
content_type: Content-Type 헤더 값
|
|
timeout: 요청 타임아웃 (초)
|
|
log_prefix: 로그 접두사
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
"""
|
|
# 파일 경로에서 파일명 추출 후 정리 (공백/특수문자 제거)
|
|
file_name = self._sanitize_filename(Path(file_path).name)
|
|
|
|
upload_url = self._build_upload_url(category, file_name)
|
|
self._last_public_url = self._build_public_url(category, file_name)
|
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
|
|
async def iter_file() -> AsyncIterator[bytes]:
|
|
async with aiofiles.open(file_path, "rb") as file:
|
|
while chunk := await file.read(
|
|
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
|
|
):
|
|
yield chunk
|
|
|
|
return await self._upload_stream(
|
|
chunks=iter_file(),
|
|
upload_url=upload_url,
|
|
content_type=content_type,
|
|
timeout=timeout,
|
|
log_prefix=log_prefix,
|
|
expected_size_bytes=Path(file_path).stat().st_size,
|
|
)
|
|
|
|
async def upload_music(self, file_path: str) -> bool:
|
|
"""음악 파일을 Azure Blob Storage에 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/song/{파일명}
|
|
|
|
Args:
|
|
file_path: 업로드할 파일 경로
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
success = await uploader.upload_music(file_path="my_song.mp3")
|
|
print(uploader.public_url)
|
|
"""
|
|
return await self._upload_file(
|
|
file_path=file_path,
|
|
category="song",
|
|
content_type="audio/mpeg",
|
|
timeout=120.0,
|
|
log_prefix="upload_music",
|
|
)
|
|
|
|
async def upload_music_bytes(
|
|
self, file_content: bytes, file_name: str
|
|
) -> bool:
|
|
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/song/{파일명}
|
|
|
|
Args:
|
|
file_content: 업로드할 파일 바이트 데이터
|
|
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
|
|
print(uploader.public_url)
|
|
"""
|
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp3 추가
|
|
file_name = self._sanitize_filename(file_name)
|
|
if not Path(file_name).suffix:
|
|
file_name = f"{file_name}.mp3"
|
|
|
|
upload_url = self._build_upload_url("song", file_name)
|
|
self._last_public_url = self._build_public_url("song", file_name)
|
|
log_prefix = "upload_music_bytes"
|
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
|
|
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
|
|
|
|
return await self._upload_bytes(
|
|
file_content=file_content,
|
|
upload_url=upload_url,
|
|
headers=headers,
|
|
timeout=120.0,
|
|
log_prefix=log_prefix,
|
|
)
|
|
|
|
async def upload_video(self, file_path: str) -> bool:
|
|
"""영상 파일을 Azure Blob Storage에 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/video/{파일명}
|
|
|
|
Args:
|
|
file_path: 업로드할 파일 경로
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
success = await uploader.upload_video(file_path="my_video.mp4")
|
|
print(uploader.public_url)
|
|
"""
|
|
return await self._upload_file(
|
|
file_path=file_path,
|
|
category="video",
|
|
content_type="video/mp4",
|
|
timeout=180.0,
|
|
log_prefix="upload_video",
|
|
)
|
|
|
|
async def upload_video_bytes(
|
|
self, file_content: bytes, file_name: str
|
|
) -> bool:
|
|
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/video/{파일명}
|
|
|
|
Args:
|
|
file_content: 업로드할 파일 바이트 데이터
|
|
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
success = await uploader.upload_video_bytes(video_bytes, "my_video")
|
|
print(uploader.public_url)
|
|
"""
|
|
# 파일명 정리 (공백/특수문자 제거) 후 확장자가 없으면 .mp4 추가
|
|
file_name = self._sanitize_filename(file_name)
|
|
if not Path(file_name).suffix:
|
|
file_name = f"{file_name}.mp4"
|
|
|
|
upload_url = self._build_upload_url("video", file_name)
|
|
self._last_public_url = self._build_public_url("video", file_name)
|
|
log_prefix = "upload_video_bytes"
|
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
|
|
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
|
|
|
|
return await self._upload_bytes(
|
|
file_content=file_content,
|
|
upload_url=upload_url,
|
|
headers=headers,
|
|
timeout=180.0,
|
|
log_prefix=log_prefix,
|
|
)
|
|
|
|
async def upload_image(self, file_path: str) -> bool:
|
|
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/image/{파일명}
|
|
|
|
Args:
|
|
file_path: 업로드할 파일 경로
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
success = await uploader.upload_image(file_path="my_image.png")
|
|
print(uploader.public_url)
|
|
"""
|
|
extension = Path(file_path).suffix.lower()
|
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
|
|
return await self._upload_file(
|
|
file_path=file_path,
|
|
category="image",
|
|
content_type=content_type,
|
|
timeout=60.0,
|
|
log_prefix="upload_image",
|
|
)
|
|
|
|
async def upload_image_bytes(
|
|
self, file_content: bytes, file_name: str
|
|
) -> bool:
|
|
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
|
|
|
|
URL 경로: {user_uuid}/{task_id}/image/{파일명}
|
|
|
|
Args:
|
|
file_content: 업로드할 파일 바이트 데이터
|
|
file_name: 저장할 파일명
|
|
|
|
Returns:
|
|
bool: 업로드 성공 여부
|
|
|
|
Example:
|
|
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
with open("my_image.png", "rb") as f:
|
|
content = f.read()
|
|
success = await uploader.upload_image_bytes(content, "my_image.png")
|
|
print(uploader.public_url)
|
|
"""
|
|
# Content-Type 결정을 위해 먼저 확장자 추출
|
|
extension = Path(file_name).suffix.lower()
|
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
|
|
# 파일명 정리 (공백/특수문자 제거)
|
|
file_name = self._sanitize_filename(file_name)
|
|
|
|
upload_url = self._build_upload_url("image", file_name)
|
|
self._last_public_url = self._build_public_url("image", file_name)
|
|
log_prefix = "upload_image_bytes"
|
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
|
|
|
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
|
|
|
return await self._upload_bytes(
|
|
file_content=file_content,
|
|
upload_url=upload_url,
|
|
headers=headers,
|
|
timeout=60.0,
|
|
log_prefix=log_prefix,
|
|
)
|
|
|
|
async def upload_image_stream(
|
|
self,
|
|
file: UploadFile,
|
|
file_name: str,
|
|
*,
|
|
expected_size_bytes: int | None = None,
|
|
max_size_bytes: int | None = None,
|
|
) -> bool:
|
|
"""FastAPI UploadFile을 Azure Block Blob으로 청크 업로드합니다."""
|
|
extension = Path(file_name).suffix.lower()
|
|
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
|
|
file_name = self._sanitize_filename(file_name)
|
|
|
|
upload_url = self._build_upload_url("image", file_name)
|
|
self._last_public_url = self._build_public_url("image", file_name)
|
|
log_prefix = "upload_image_stream"
|
|
chunk_size = azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
|
|
max_size = (
|
|
max_size_bytes
|
|
if max_size_bytes is not None
|
|
else azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
|
|
)
|
|
|
|
async def iter_upload() -> AsyncIterator[bytes]:
|
|
await file.seek(0)
|
|
while chunk := await file.read(chunk_size):
|
|
yield chunk
|
|
|
|
return await self._upload_stream(
|
|
chunks=iter_upload(),
|
|
upload_url=upload_url,
|
|
content_type=content_type,
|
|
timeout=60.0,
|
|
log_prefix=log_prefix,
|
|
max_size_bytes=max_size,
|
|
expected_size_bytes=expected_size_bytes,
|
|
cleanup_blob_on_failure=True,
|
|
)
|
|
|
|
async def delete_image(self, file_name: str) -> bool:
|
|
"""이미지 Blob을 삭제합니다. DB 저장 실패 보상 처리용입니다."""
|
|
sanitized_name = self._sanitize_filename(file_name)
|
|
upload_url = self._build_upload_url("image", sanitized_name)
|
|
return await self._delete_upload_url(upload_url, "delete_image")
|
|
|
|
|
|
# 사용 예시:
|
|
# import asyncio
|
|
#
|
|
# async def main():
|
|
# uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
|
|
#
|
|
# # 음악 업로드 -> {BASE_URL}/user-abc/task-123/song/my_song.mp3
|
|
# await uploader.upload_music("my_song.mp3")
|
|
# print(uploader.public_url)
|
|
#
|
|
# # 영상 업로드 -> {BASE_URL}/user-abc/task-123/video/my_video.mp4
|
|
# await uploader.upload_video("my_video.mp4")
|
|
# print(uploader.public_url)
|
|
#
|
|
# # 이미지 업로드 -> {BASE_URL}/user-abc/task-123/image/my_image.png
|
|
# await uploader.upload_image("my_image.png")
|
|
# print(uploader.public_url)
|
|
#
|
|
# asyncio.run(main())
|