# -*- coding: utf-8 -*- """P2V 산출물 Azure Blob 아카이빙. P2V 서버는 콜백이 없으므로, 폴링 핸들러가 'done' 을 처음 관측했을 때 FastAPI BackgroundTasks 로 이 모듈을 태운다 (짧은 async I/O — 스레드 워커는 과함). 업로더는 castad `AzureBlobUploader` 를 그대로 재사용한다 (ssulbox blob_service 와 같은 이유 — 같은 Azure 계정에 업로더를 두 벌 둘 이유가 없다). 경로: {user_uuid}/{P2V_BLOB_PREFIX}-f1-{id}/video/... — castad·썰박스와 분리된다. 중복 실행 방지: try_claim_archiving 낙관적 잠금이 첫 태스크에게만 착수권을 준다. 실패 복구: 예외 시 status 를 'done'(archived_at NULL)으로 되돌린다 — 다음 폴링이 다시 선점해 재시도한다. 크래시로 'archiving' 에 갇힌 행은 기동 시 스윕이 되돌린다. """ from app.database.session import BackgroundSessionLocal from app.p2v.constants import STATUS_DONE from app.p2v.models import P2vF1Job, P2vF2Job from app.p2v.services import client, f1_service, f2_service from app.utils.logger import get_logger from app.utils.upload_blob_as_request import AzureBlobUploader from config import azure_blob_settings, p2v_settings logger = get_logger("p2v") #: 설정되지 않았을 때의 플레이스홀더 (ssulbox blob_service 와 동일 판정) _PLACEHOLDER_SAS = {"", "your-sas-token", "none"} def blob_enabled() -> bool: """Blob 업로드가 가능한 상태인지. 비활성이면 URL 없이 done 처리된다(개발 환경).""" token = (azure_blob_settings.AZURE_BLOB_SAS_TOKEN or "").strip() return token.lower() not in _PLACEHOLDER_SAS def _uploader(user_uuid: str, kind: str, row_id: int) -> AzureBlobUploader: return AzureBlobUploader( user_uuid=user_uuid, task_id=f"{p2v_settings.P2V_BLOB_PREFIX}-{kind}-{row_id}", ) async def upload_source_image( kind: str, row_id: int, user_uuid: str, content: bytes, filename: str ) -> None: """원본 업로드 포스터를 Blob 에 보관한다 (잡 생성 직후 백그라운드). 생성 요청이 이미 파일 바이트를 들고 있으므로, 나중에 P2V 에서 되받아오는 대신 이 시점에 바로 올린다. 실패해도 잡 진행에는 영향을 주지 않는다(로그만). """ if not blob_enabled(): return try: uploader = _uploader(user_uuid, kind, row_id) ok = await uploader.upload_image_bytes(content, filename or "source.png") if not ok: logger.warning(f"[archive] 원본 업로드 실패 {kind} id={row_id}") return url = uploader.public_url model = P2vF1Job if kind == "f1" else P2vF2Job async with BackgroundSessionLocal() as session: row = await session.get(model, row_id) if row is not None: row.source_image_url = url await session.commit() except Exception as e: # 원본 보관은 부수 기능 — 실패가 잡을 죽이면 안 된다 logger.warning(f"[archive] 원본 업로드 예외 {kind} id={row_id}: {e}") async def _revert_claim(kind: str, row_id: int) -> None: """아카이빙 실패 → 재시도 가능 상태('done', archived_at NULL)로 복구.""" model = P2vF1Job if kind == "f1" else P2vF2Job try: async with BackgroundSessionLocal() as session: row = await session.get(model, row_id) if row is not None and row.archived_at is None: row.status = STATUS_DONE await session.commit() except Exception as e: logger.error(f"[archive] 복구 실패 {kind} id={row_id}: {e} — 기동 스윕이 처리한다") async def archive_f1(row_id: int) -> None: """F1 완성 영상·썸네일을 Blob 으로 옮기고 done 전이한다.""" async with BackgroundSessionLocal() as session: if not await f1_service.try_claim_archiving(session, row_id): return # 다른 폴링이 이미 집어갔다 await session.commit() try: async with BackgroundSessionLocal() as session: row = await session.get(P2vF1Job, row_id) if row is None or not row.p2v_job_id: return video_url = None thumb_url = None if blob_enabled(): p2v_job = await client.request_json( "GET", f"/api/f1/jobs/{row.p2v_job_id}" ) artifacts = p2v_job.get("artifacts") or {} uploader = _uploader(row.user_uuid, "f1", row_id) if artifacts.get("video"): content, _ = await client.download(artifacts["video"]) if await uploader.upload_video_bytes(content, "video.mp4"): video_url = uploader.public_url else: raise RuntimeError("영상 Blob 업로드 실패") if artifacts.get("thumbnail"): content, _ = await client.download(artifacts["thumbnail"]) if await uploader.upload_image_bytes(content, "thumbnail.jpg"): thumb_url = uploader.public_url else: # 조용히 넘기면 poster_url(og:image)이 영구 결손된다 — # 영상과 같이 실패시켜 다음 폴링이 아카이빙을 재시도하게 한다 raise RuntimeError("썸네일 Blob 업로드 실패") # 확정 메타데이터도 이 시점에 마지막으로 미러링해 둔다 f1_service.sync_from_p2v(row, p2v_job) else: logger.warning( f"[archive] Blob 비활성 — f1 id={row_id} URL 없이 done 처리 (개발 환경)" ) await f1_service.finish_archive( session, row, video_url=video_url, thumbnail_url=thumb_url ) await session.commit() except Exception as e: logger.error(f"[archive] f1 id={row_id} 아카이빙 실패: {type(e).__name__}: {e}") await _revert_claim("f1", row_id) async def archive_f2(row_id: int) -> None: """F2 결과 이미지를 Blob 으로 옮기고 done 전이한다.""" async with BackgroundSessionLocal() as session: if not await f2_service.try_claim_archiving(session, row_id): return await session.commit() try: async with BackgroundSessionLocal() as session: row = await session.get(P2vF2Job, row_id) if row is None or not row.p2v_job_id: return image_url = None if blob_enabled(): p2v_job = await client.request_json( "GET", f"/api/f2/jobs/{row.p2v_job_id}" ) artifacts = p2v_job.get("artifacts") or {} if artifacts.get("image"): content, _ = await client.download(artifacts["image"]) uploader = _uploader(row.user_uuid, "f2", row_id) if await uploader.upload_image_bytes(content, "result.png"): image_url = uploader.public_url else: raise RuntimeError("결과 이미지 Blob 업로드 실패") else: logger.warning( f"[archive] Blob 비활성 — f2 id={row_id} URL 없이 done 처리 (개발 환경)" ) await f2_service.finish_archive(session, row, image_url=image_url) await session.commit() except Exception as e: logger.error(f"[archive] f2 id={row_id} 아카이빙 실패: {type(e).__name__}: {e}") await _revert_claim("f2", row_id)