172 lines
7.6 KiB
Python
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)
|