288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""
|
|
Song Background Tasks
|
|
|
|
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
|
"""
|
|
|
|
import traceback
|
|
from datetime import date
|
|
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.common import generate_task_id
|
|
from app.utils.logger import get_logger
|
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
|
from config import prj_settings
|
|
|
|
# 로거 설정
|
|
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,
|
|
) -> bool:
|
|
"""Song 테이블의 상태를 업데이트합니다.
|
|
|
|
Args:
|
|
task_id: 프로젝트 task_id
|
|
status: 변경할 상태 ("processing", "completed", "failed")
|
|
song_url: 노래 URL
|
|
suno_task_id: Suno task ID (선택)
|
|
duration: 노래 길이 (선택)
|
|
|
|
Returns:
|
|
bool: 업데이트 성공 여부
|
|
"""
|
|
try:
|
|
async with BackgroundSessionLocal() as session:
|
|
if suno_task_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:
|
|
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}, status: {status}")
|
|
return True
|
|
else:
|
|
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
|
|
return False
|
|
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_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_save_song(
|
|
task_id: str,
|
|
audio_url: str,
|
|
store_name: str,
|
|
) -> None:
|
|
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
|
|
|
|
Args:
|
|
task_id: 프로젝트 task_id
|
|
audio_url: 다운로드할 오디오 URL
|
|
store_name: 저장할 파일명에 사용할 업체명
|
|
"""
|
|
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
|
|
|
|
try:
|
|
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
|
today = date.today().strftime("%Y-%m-%d")
|
|
unique_id = await generate_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 "song"
|
|
file_name = f"{safe_store_name}.mp3"
|
|
|
|
# 절대 경로 생성
|
|
media_dir = Path("media") / "song" / today / unique_id
|
|
media_dir.mkdir(parents=True, exist_ok=True)
|
|
file_path = media_dir / file_name
|
|
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
|
|
|
|
# 오디오 파일 다운로드
|
|
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
|
|
|
content = await _download_audio(audio_url, task_id)
|
|
|
|
async with aiofiles.open(str(file_path), "wb") as f:
|
|
await f.write(content)
|
|
|
|
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
|
|
|
|
# 프론트엔드에서 접근 가능한 URL 생성
|
|
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
|
base_url = f"{prj_settings.PROJECT_DOMAIN}"
|
|
file_url = f"{base_url}{relative_path}"
|
|
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
|
|
|
|
# Song 테이블 업데이트
|
|
await _update_song_status(task_id, "completed", file_url)
|
|
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
await _update_song_status(task_id, "failed")
|
|
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
|
await _update_song_status(task_id, "failed")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
|
await _update_song_status(task_id, "failed")
|
|
|
|
|
|
async def download_and_upload_song_by_suno_task_id(
|
|
suno_task_id: str,
|
|
audio_url: str,
|
|
store_name: str,
|
|
duration: float | None = None,
|
|
) -> None:
|
|
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
|
|
|
|
Args:
|
|
suno_task_id: Suno API 작업 ID
|
|
audio_url: 다운로드할 오디오 URL
|
|
store_name: 저장할 파일명에 사용할 업체명
|
|
duration: 노래 재생 시간 (초)
|
|
"""
|
|
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, 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}")
|
|
|
|
# 파일명에 사용할 수 없는 문자 제거
|
|
safe_store_name = "".join(
|
|
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
|
|
).strip()
|
|
safe_store_name = safe_store_name or "song"
|
|
file_name = f"{safe_store_name}.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(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 # 디렉토리가 비어있지 않으면 무시
|