""" Video Background Tasks 영상 생성 관련 백그라운드 태스크를 정의합니다. """ import traceback from pathlib import Path import aiofiles import httpx from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from app.database.session import BackgroundSessionLocal from app.video.models import Video from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.logger import get_logger # 로거 설정 logger = get_logger("video") # HTTP 요청 설정 REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) async def _update_video_status( task_id: str, status: str, video_url: str | None = None, creatomate_render_id: str | None = None, ) -> bool: """Video 테이블의 상태를 업데이트합니다. Args: task_id: 프로젝트 task_id status: 변경할 상태 ("processing", "completed", "failed") video_url: 영상 URL creatomate_render_id: Creatomate render ID (선택) Returns: bool: 업데이트 성공 여부 """ try: async with BackgroundSessionLocal() as session: if creatomate_render_id: query_result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) .order_by(Video.created_at.desc()) .limit(1) ) else: query_result = await session.execute( select(Video) .where(Video.task_id == task_id) .order_by(Video.created_at.desc()) .limit(1) ) video = query_result.scalar_one_or_none() if video: video.status = status if video_url is not None: video.result_movie_url = video_url await session.commit() logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") return True else: logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") return False except SQLAlchemyError as e: logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") return False except Exception as e: logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") return False async def _download_video(url: str, task_id: str) -> bytes: """URL에서 영상을 다운로드합니다. Args: url: 다운로드할 URL task_id: 로그용 task_id Returns: bytes: 다운로드한 파일 내용 Raises: httpx.HTTPError: 다운로드 실패 시 """ logger.info(f"[VideoDownload] Downloading - task_id: {task_id}") async with httpx.AsyncClient() as client: response = await client.get(url, timeout=REQUEST_TIMEOUT) response.raise_for_status() logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") return response.content async def download_and_upload_video_to_blob( task_id: str, video_url: str, store_name: str, user_uuid: str, ) -> None: """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. Args: task_id: 프로젝트 task_id video_url: 다운로드할 영상 URL store_name: 저장할 파일명에 사용할 업체명 user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") temp_file_path: Path | None = None try: # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( c for c in store_name if c.isalnum() or c in (" ", "_", "-") ).strip() safe_store_name = safe_store_name or "video" file_name = f"{safe_store_name}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.debug(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") content = await _download_video(video_url, task_id) async with aiofiles.open(str(temp_file_path), "wb") as f: await f.write(content) logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) upload_success = await uploader.upload_video(file_path=str(temp_file_path)) if not upload_success: raise Exception("Azure Blob Storage 업로드 실패") # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") # Video 테이블 업데이트 await _update_video_status(task_id, "completed", blob_url) logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") except httpx.HTTPError as e: logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") except SQLAlchemyError as e: logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") except Exception as e: logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True) await _update_video_status(task_id, "failed") finally: # 임시 파일 삭제 if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 temp_dir = Path("media") / "temp" / task_id if temp_dir.exists(): try: temp_dir.rmdir() except Exception: pass # 디렉토리가 비어있지 않으면 무시 async def download_and_upload_video_by_creatomate_render_id( creatomate_render_id: str, video_url: str, store_name: str, user_uuid: str, ) -> None: """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. Args: creatomate_render_id: Creatomate API 렌더 ID video_url: 다운로드할 영상 URL store_name: 저장할 파일명에 사용할 업체명 user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용) """ logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") temp_file_path: Path | None = None task_id: str | None = None try: # creatomate_render_id로 Video 조회하여 task_id 가져오기 async with BackgroundSessionLocal() as session: result = await session.execute( select(Video) .where(Video.creatomate_render_id == creatomate_render_id) .order_by(Video.created_at.desc()) .limit(1) ) video = result.scalar_one_or_none() if not video: logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") return task_id = video.task_id logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") # 파일명에 사용할 수 없는 문자 제거 safe_store_name = "".join( c for c in store_name if c.isalnum() or c in (" ", "_", "-") ).strip() safe_store_name = safe_store_name or "video" file_name = f"{safe_store_name}.mp4" # 임시 저장 경로 생성 temp_dir = Path("media") / "temp" / task_id temp_dir.mkdir(parents=True, exist_ok=True) temp_file_path = temp_dir / file_name logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") # 영상 파일 다운로드 logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") content = await _download_video(video_url, task_id) async with aiofiles.open(str(temp_file_path), "wb") as f: await f.write(content) logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") # Azure Blob Storage에 업로드 uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id) upload_success = await uploader.upload_video(file_path=str(temp_file_path)) if not upload_success: raise Exception("Azure Blob Storage 업로드 실패") # SAS 토큰이 제외된 public_url 사용 blob_url = uploader.public_url logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") # Video 테이블 업데이트 await _update_video_status( task_id=task_id, status="completed", video_url=blob_url, creatomate_render_id=creatomate_render_id, ) logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") except httpx.HTTPError as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) except SQLAlchemyError as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) except Exception as e: logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True) if task_id: await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) finally: # 임시 파일 삭제 if temp_file_path and temp_file_path.exists(): try: temp_file_path.unlink() logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") except Exception as e: logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") # 임시 디렉토리 삭제 시도 if task_id: temp_dir = Path("media") / "temp" / task_id if temp_dir.exists(): try: temp_dir.rmdir() except Exception: pass # 디렉토리가 비어있지 않으면 무시