o2o-castad-backend/app/song/worker/song_task.py

227 lines
8.9 KiB
Python

"""
Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다.
"""
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.song.models import Song
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
# 로거 설정
logger = get_logger("song")
# HTTP 요청 설정
REQUEST_TIMEOUT = 120.0 # 초
async def _update_song_status(
task_id: str,
status: str,
song_url: str | None = None,
suno_task_id: str | None = None,
duration: float | None = None,
song_id: int | None = None,
) -> bool:
"""Song 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
song_url: 노래 URL
suno_task_id: Suno task ID (선택)
duration: 노래 길이 (선택)
song_id: 특정 Song 레코드 ID (재생성 시 정확한 레코드 식별용)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
if song_id:
# song_id로 특정 레코드 조회 (가장 정확한 식별)
query_result = await session.execute(
select(Song).where(Song.id == song_id)
)
elif suno_task_id:
# suno_task_id로 조회 (Suno API 고유 ID)
query_result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
else:
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
query_result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = query_result.scalar_one_or_none()
if song:
song.status = status
if song_url is not None:
song.song_result_url = song_url
if duration is not None:
song.duration = duration
await session.commit()
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
return True
else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
return False
async def _download_audio(url: str, task_id: str) -> bytes:
"""URL에서 오디오 파일을 다운로드합니다.
Args:
url: 다운로드할 URL
task_id: 로그용 task_id
Returns:
bytes: 다운로드한 파일 내용
Raises:
httpx.HTTPError: 다운로드 실패 시
"""
logger.info(f"[Download] 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"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
async def download_and_upload_song_by_suno_task_id(
suno_task_id: str,
audio_url: str,
user_uuid: str,
duration: float | None = None,
) -> None:
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
파일명은 suno_task_id를 사용하여 고유성을 보장합니다.
Args:
suno_task_id: Suno API 작업 ID (파일명으로도 사용)
audio_url: 다운로드할 오디오 URL
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
duration: 노래 재생 시간 (초)
"""
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, duration: {duration}")
temp_file_path: Path | None = None
task_id: str | None = None
try:
# suno_task_id로 Song 조회하여 task_id 가져오기
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if not song:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return
task_id = song.task_id
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
# suno_task_id를 파일명으로 사용 (고유 ID이므로 sanitize 불필요)
file_name = f"{suno_task_id}.mp3"
# 임시 저장 경로 생성
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_music(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_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
# Song 테이블 업데이트
await _update_song_status(
task_id=task_id,
status="completed",
song_url=blob_url,
suno_task_id=suno_task_id,
duration=duration,
)
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except Exception as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_by_suno_task_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 # 디렉토리가 비어있지 않으면 무시