o2o-castad-backend/app/p2v/services/archive_service.py
2026-08-26 13:52:18 +09:00

172 lines
7.6 KiB
Python

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