Compare commits

...

7 Commits

Author SHA1 Message Date
jaehwang ba26284451 Merge branch 'main' into scraper-poc 2026-01-13 15:50:58 +09:00
Dohyun Lim 3f75b6d61d add facilities from result of crawling 2026-01-12 16:50:16 +09:00
Dohyun Lim b84c07c325 update toml 2026-01-12 14:29:50 +09:00
Dohyun Lim 94aae50564 update crawler for short url 2026-01-12 13:46:28 +09:00
Dohyun Lim 2b777f5314 remove .gitkeep 2026-01-09 10:31:30 +09:00
Dohyun Lim 1199eca649 upgrade marketing template 2026-01-09 10:30:03 +09:00
Dohyun Lim 073777081e add logs for tracing processing task 2026-01-08 14:05:44 +09:00
95 changed files with 24175 additions and 22969 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,38 +1,38 @@
from fastapi import FastAPI
from sqladmin import Admin
from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin
from app.video.api.video_admin import VideoAdmin
from config import prj_settings
# https://github.com/aminalaee/sqladmin
def init_admin(
app: FastAPI,
db_engine: engine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
admin = Admin(
app,
db_engine,
base_url=base_url,
)
# 프로젝트 관리
admin.add_view(ProjectAdmin)
admin.add_view(ImageAdmin)
# 가사 관리
admin.add_view(LyricAdmin)
# 노래 관리
admin.add_view(SongAdmin)
# 영상 관리
admin.add_view(VideoAdmin)
return admin
from fastapi import FastAPI
from sqladmin import Admin
from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin
from app.video.api.video_admin import VideoAdmin
from config import prj_settings
# https://github.com/aminalaee/sqladmin
def init_admin(
app: FastAPI,
db_engine: engine,
base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin:
admin = Admin(
app,
db_engine,
base_url=base_url,
)
# 프로젝트 관리
admin.add_view(ProjectAdmin)
admin.add_view(ImageAdmin)
# 가사 관리
admin.add_view(LyricAdmin)
# 노래 관리
admin.add_view(SongAdmin)
# 영상 관리
admin.add_view(VideoAdmin)
return admin

View File

@ -1,51 +1,51 @@
# app/main.py
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 애플리케이션 생명주기 관리"""
# Startup - 애플리케이션 시작 시
print("Starting up...")
try:
from config import prj_settings
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
if prj_settings.DEBUG:
from app.database.session import create_db_tables
await create_db_tables()
print("Database tables created (DEBUG mode)")
except asyncio.TimeoutError:
print("Database initialization timed out")
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
except Exception as e:
print(f"Database initialization failed: {e}")
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
yield # 애플리케이션 실행 중
# Shutdown - 애플리케이션 종료 시
print("Shutting down...")
# 공유 HTTP 클라이언트 종료
from app.utils.creatomate import close_shared_client
from app.utils.upload_blob_as_request import close_shared_blob_client
await close_shared_client()
await close_shared_blob_client()
# 데이터베이스 엔진 종료
from app.database.session import dispose_engine
await dispose_engine()
# FastAPI 앱 생성 (lifespan 적용)
app = FastAPI(title="CastAD", lifespan=lifespan)
# app/main.py
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 애플리케이션 생명주기 관리"""
# Startup - 애플리케이션 시작 시
print("Starting up...")
try:
from config import prj_settings
# DEBUG 모드일 때만 데이터베이스 테이블 자동 생성
if prj_settings.DEBUG:
from app.database.session import create_db_tables
await create_db_tables()
print("Database tables created (DEBUG mode)")
except asyncio.TimeoutError:
print("Database initialization timed out")
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
except Exception as e:
print(f"Database initialization failed: {e}")
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
yield # 애플리케이션 실행 중
# Shutdown - 애플리케이션 종료 시
print("Shutting down...")
# 공유 HTTP 클라이언트 종료
from app.utils.creatomate import close_shared_client
from app.utils.upload_blob_as_request import close_shared_blob_client
await close_shared_client()
await close_shared_blob_client()
# 데이터베이스 엔진 종료
from app.database.session import dispose_engine
await dispose_engine()
# FastAPI 앱 생성 (lifespan 적용)
app = FastAPI(title="CastAD", lifespan=lifespan)

View File

@ -1,114 +1,313 @@
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
class FastShipError(Exception):
"""Base exception for all exceptions in fastship api"""
# status_code to be returned for this exception
# when it is handled
status = status.HTTP_400_BAD_REQUEST
class EntityNotFound(FastShipError):
"""Entity not found in database"""
status = status.HTTP_404_NOT_FOUND
class BadPassword(FastShipError):
"""Password is not strong enough or invalid"""
status = status.HTTP_400_BAD_REQUEST
class ClientNotAuthorized(FastShipError):
"""Client is not authorized to perform the action"""
status = status.HTTP_401_UNAUTHORIZED
class ClientNotVerified(FastShipError):
"""Client is not verified"""
status = status.HTTP_401_UNAUTHORIZED
class NothingToUpdate(FastShipError):
"""No data provided to update"""
class BadCredentials(FastShipError):
"""User email or password is incorrect"""
status = status.HTTP_401_UNAUTHORIZED
class InvalidToken(FastShipError):
"""Access token is invalid or expired"""
status = status.HTTP_401_UNAUTHORIZED
class DeliveryPartnerNotAvailable(FastShipError):
"""Delivery partner/s do not service the destination"""
status = status.HTTP_406_NOT_ACCEPTABLE
class DeliveryPartnerCapacityExceeded(FastShipError):
"""Delivery partner has reached their max handling capacity"""
status = status.HTTP_406_NOT_ACCEPTABLE
def _get_handler(status: int, detail: str):
# Define
def handler(request: Request, exception: Exception) -> Response:
# DEBUG PRINT STATEMENT 👇
from rich import print, panel
print(
panel.Panel(
exception.__class__.__name__,
title="Handled Exception",
border_style="red",
),
)
# DEBUG PRINT STATEMENT 👆
# Raise HTTPException with given status and detail
# can return JSONResponse as well
raise HTTPException(
status_code=status,
detail=detail,
)
# Return ExceptionHandler required with given
# status and detail for HTTPExcetion above
return handler
def add_exception_handlers(app: FastAPI):
# Get all subclass of 👇, our custom exceptions
exception_classes = FastShipError.__subclasses__()
for exception_class in exception_classes:
# Add exception handler
app.add_exception_handler(
# Custom exception class
exception_class,
# Get handler function
_get_handler(
status=exception_class.status,
detail=exception_class.__doc__,
),
)
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception):
return JSONResponse(
content={"detail": "Something went wrong..."},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
headers={
"X-Error": f"{exception}",
}
)
import logging
import traceback
from functools import wraps
from typing import Any, Callable, TypeVar
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
# 로거 설정
logger = logging.getLogger(__name__)
T = TypeVar("T")
class FastShipError(Exception):
"""Base exception for all exceptions in fastship api"""
# status_code to be returned for this exception
# when it is handled
status = status.HTTP_400_BAD_REQUEST
class EntityNotFound(FastShipError):
"""Entity not found in database"""
status = status.HTTP_404_NOT_FOUND
class BadPassword(FastShipError):
"""Password is not strong enough or invalid"""
status = status.HTTP_400_BAD_REQUEST
class ClientNotAuthorized(FastShipError):
"""Client is not authorized to perform the action"""
status = status.HTTP_401_UNAUTHORIZED
class ClientNotVerified(FastShipError):
"""Client is not verified"""
status = status.HTTP_401_UNAUTHORIZED
class NothingToUpdate(FastShipError):
"""No data provided to update"""
class BadCredentials(FastShipError):
"""User email or password is incorrect"""
status = status.HTTP_401_UNAUTHORIZED
class InvalidToken(FastShipError):
"""Access token is invalid or expired"""
status = status.HTTP_401_UNAUTHORIZED
class DeliveryPartnerNotAvailable(FastShipError):
"""Delivery partner/s do not service the destination"""
status = status.HTTP_406_NOT_ACCEPTABLE
class DeliveryPartnerCapacityExceeded(FastShipError):
"""Delivery partner has reached their max handling capacity"""
status = status.HTTP_406_NOT_ACCEPTABLE
# =============================================================================
# 데이터베이스 관련 예외
# =============================================================================
class DatabaseError(FastShipError):
"""Database operation failed"""
status = status.HTTP_503_SERVICE_UNAVAILABLE
class DatabaseConnectionError(DatabaseError):
"""Database connection failed"""
status = status.HTTP_503_SERVICE_UNAVAILABLE
class DatabaseTimeoutError(DatabaseError):
"""Database operation timed out"""
status = status.HTTP_504_GATEWAY_TIMEOUT
# =============================================================================
# 외부 서비스 관련 예외
# =============================================================================
class ExternalServiceError(FastShipError):
"""External service call failed"""
status = status.HTTP_502_BAD_GATEWAY
class GPTServiceError(ExternalServiceError):
"""GPT API call failed"""
status = status.HTTP_502_BAD_GATEWAY
class CrawlingError(ExternalServiceError):
"""Web crawling failed"""
status = status.HTTP_502_BAD_GATEWAY
class BlobStorageError(ExternalServiceError):
"""Azure Blob Storage operation failed"""
status = status.HTTP_502_BAD_GATEWAY
class CreatomateError(ExternalServiceError):
"""Creatomate API call failed"""
status = status.HTTP_502_BAD_GATEWAY
# =============================================================================
# 예외 처리 데코레이터
# =============================================================================
def handle_db_exceptions(
error_message: str = "데이터베이스 작업 중 오류가 발생했습니다.",
):
"""데이터베이스 예외를 처리하는 데코레이터.
Args:
error_message: 오류 발생 반환할 메시지
Example:
@handle_db_exceptions("사용자 조회 중 오류 발생")
async def get_user(user_id: int):
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
# HTTPException은 그대로 raise
raise
except SQLAlchemyError as e:
logger.error(f"[DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[DB Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=error_message,
)
except Exception as e:
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[Unexpected Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
)
return wrapper
return decorator
def handle_external_service_exceptions(
service_name: str = "외부 서비스",
error_message: str | None = None,
):
"""외부 서비스 호출 예외를 처리하는 데코레이터.
Args:
service_name: 서비스 이름 (로그용)
error_message: 오류 발생 반환할 메시지
Example:
@handle_external_service_exceptions("GPT")
async def call_gpt():
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
raise
except Exception as e:
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[{service_name} Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=msg,
)
return wrapper
return decorator
def handle_api_exceptions(
error_message: str = "요청 처리 중 오류가 발생했습니다.",
):
"""API 엔드포인트 예외를 처리하는 데코레이터.
Args:
error_message: 오류 발생 반환할 메시지
Example:
@handle_api_exceptions("가사 생성 중 오류 발생")
async def generate_lyric():
...
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
try:
return await func(*args, **kwargs)
except HTTPException:
raise
except SQLAlchemyError as e:
logger.error(f"[API DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API DB Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
logger.error(f"[API Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API Error] {func.__name__}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_message,
)
return wrapper
return decorator
def _get_handler(status: int, detail: str):
# Define
def handler(request: Request, exception: Exception) -> Response:
# DEBUG PRINT STATEMENT 👇
from rich import print, panel
print(
panel.Panel(
exception.__class__.__name__,
title="Handled Exception",
border_style="red",
),
)
# DEBUG PRINT STATEMENT 👆
# Raise HTTPException with given status and detail
# can return JSONResponse as well
raise HTTPException(
status_code=status,
detail=detail,
)
# Return ExceptionHandler required with given
# status and detail for HTTPExcetion above
return handler
def add_exception_handlers(app: FastAPI):
# Get all subclass of 👇, our custom exceptions
exception_classes = FastShipError.__subclasses__()
for exception_class in exception_classes:
# Add exception handler
app.add_exception_handler(
# Custom exception class
exception_class,
# Get handler function
_get_handler(
status=exception_class.status,
detail=exception_class.__doc__,
),
)
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception):
return JSONResponse(
content={"detail": "Something went wrong..."},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
headers={
"X-Error": f"{exception}",
}
)

View File

@ -1,30 +1,30 @@
from uuid import UUID
from redis.asyncio import Redis
from app.config import db_settings
_token_blacklist = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
)
_shipment_verification_codes = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=1,
decode_responses=True,
)
async def add_jti_to_blacklist(jti: str):
await _token_blacklist.set(jti, "blacklisted")
async def is_jti_blacklisted(jti: str) -> bool:
return await _token_blacklist.exists(jti)
async def add_shipment_verification_code(id: UUID, code: int):
await _shipment_verification_codes.set(str(id), code)
async def get_shipment_verification_code(id: UUID) -> str:
from uuid import UUID
from redis.asyncio import Redis
from app.config import db_settings
_token_blacklist = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
)
_shipment_verification_codes = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=1,
decode_responses=True,
)
async def add_jti_to_blacklist(jti: str):
await _token_blacklist.set(jti, "blacklisted")
async def is_jti_blacklisted(jti: str) -> bool:
return await _token_blacklist.exists(jti)
async def add_shipment_verification_code(id: UUID, code: int):
await _shipment_verification_codes.set(str(id), code)
async def get_shipment_verification_code(id: UUID) -> str:
return str(await _shipment_verification_codes.get(str(id)))

View File

@ -1,97 +1,97 @@
from asyncio import current_task
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
from config import db_settings
# Base 클래스 정의
class Base(DeclarativeBase):
pass
engine = create_async_engine(
# MySQL async URL (asyncmy 드라이버)
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
# === Connection Pool 설정 ===
pool_size=10, # 기본 풀 크기: 10개 연결 유지
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
# === MySQL 특화 설정 ===
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
# === 연결 타임아웃 및 재시도 ===
connect_args={
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
"read_timeout": 30, # 읽기 타임아웃: 30초
"write_timeout": 30, # 쓰기 타임아웃: 30초
"charset": "utf8mb4", # 문자셋 (이모지 지원)
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
},
)
# Async 세션 팩토리 생성
async_session_factory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # 커밋 후 객체 상태 유지
autoflush=True, # 변경 감지 자동 플러시
)
# async_scoped_session 생성
AsyncScopedSession = async_session_factory(
async_session_factory,
scopefunc=current_task,
)
# 테이블 생성 함수
async def create_db_tables() -> None:
async with engine.begin() as conn:
# from app.database.models import Shipment, Seller # noqa: F401
await conn.run_sync(Base.metadata.create_all)
print("MySQL tables created successfully")
# 세션 제너레이터 (FastAPI Depends에 사용)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""
Async 세션 컨텍스트 매니저
- FastAPI dependency로 사용
- Connection Pool에서 연결 획득/반환 자동 관리
"""
async with async_session_factory() as session:
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
# await session.begin() # async_sessionmaker에서 자동 begin
try:
yield session
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
except Exception as e:
await session.rollback() # 명시적 롤백 (선택적)
print(f"Session rollback due to: {e}") # 로깅
raise
finally:
# 명시적 세션 종료 (Connection Pool에 반환)
# context manager가 자동 처리하지만, 명시적으로 유지
await session.close()
print("session closed successfully")
# 또는 session.aclose() - Python 3.10+
# 애플리케이션 종료 시 엔진 정리 (선택적)
async def dispose_engine() -> None:
"""애플리케이션 종료 시 모든 연결 해제"""
await engine.dispose()
print("Database engine disposed")
from asyncio import current_task
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
from config import db_settings
# Base 클래스 정의
class Base(DeclarativeBase):
pass
engine = create_async_engine(
# MySQL async URL (asyncmy 드라이버)
url=db_settings.MYSQL_URL, # 예: "mysql+asyncmy://test:test@host:3306/poc"
# === Connection Pool 설정 ===
pool_size=10, # 기본 풀 크기: 10개 연결 유지
max_overflow=10, # 최대 증가: 10개 (총 20개까지 가능)
poolclass=AsyncQueuePool, # 비동기 큐 풀 사용 (기본값, 명시적 지정)
pool_timeout=30, # 풀에서 연결 대기 시간: 30초 (기본 30초)
pool_recycle=3600, # 연결 재사용 주기: 1시간 (기본 3600초)
pool_pre_ping=True, # 연결 사용 전 유효성 검사: True로 설정
pool_reset_on_return="rollback", # 연결 반환 시 자동 롤백
# === MySQL 특화 설정 ===
echo=False, # SQL 쿼리 로깅 (디버깅 시 True)
# === 연결 타임아웃 및 재시도 ===
connect_args={
"connect_timeout": 10, # MySQL 연결 타임아웃: 10초
"read_timeout": 30, # 읽기 타임아웃: 30초
"write_timeout": 30, # 쓰기 타임아웃: 30초
"charset": "utf8mb4", # 문자셋 (이모지 지원)
"sql_mode": "STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE",
"init_command": "SET SESSION time_zone = '+00:00'", # 초기 연결 시 실행
},
)
# Async 세션 팩토리 생성
async_session_factory = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # 커밋 후 객체 상태 유지
autoflush=True, # 변경 감지 자동 플러시
)
# async_scoped_session 생성
AsyncScopedSession = async_session_factory(
async_session_factory,
scopefunc=current_task,
)
# 테이블 생성 함수
async def create_db_tables() -> None:
async with engine.begin() as conn:
# from app.database.models import Shipment, Seller # noqa: F401
await conn.run_sync(Base.metadata.create_all)
print("MySQL tables created successfully")
# 세션 제너레이터 (FastAPI Depends에 사용)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""
Async 세션 컨텍스트 매니저
- FastAPI dependency로 사용
- Connection Pool에서 연결 획득/반환 자동 관리
"""
async with async_session_factory() as session:
# pre-commit 훅 (선택적: 트랜잭션 시작 전 실행)
# await session.begin() # async_sessionmaker에서 자동 begin
try:
yield session
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
except Exception as e:
await session.rollback() # 명시적 롤백 (선택적)
print(f"Session rollback due to: {e}") # 로깅
raise
finally:
# 명시적 세션 종료 (Connection Pool에 반환)
# context manager가 자동 처리하지만, 명시적으로 유지
await session.close()
print("session closed successfully")
# 또는 session.aclose() - Python 3.10+
# 애플리케이션 종료 시 엔진 정리 (선택적)
async def dispose_engine() -> None:
"""애플리케이션 종료 시 모든 연결 해제"""
await engine.dispose()
print("Database engine disposed")

View File

@ -1,161 +1,161 @@
import time
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from config import db_settings
class Base(DeclarativeBase):
pass
# =============================================================================
# 메인 엔진 (FastAPI 요청용)
# =============================================================================
engine = create_async_engine(
url=db_settings.MYSQL_URL,
echo=False,
pool_size=20, # 기본 풀 크기: 20
max_overflow=20, # 추가 연결: 20 (총 최대 40)
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
connect_args={
"connect_timeout": 10, # DB 연결 타임아웃
"charset": "utf8mb4",
},
)
# 메인 세션 팩토리 (FastAPI DI용)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False, # 명시적 flush 권장
)
# =============================================================================
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
# =============================================================================
background_engine = create_async_engine(
url=db_settings.MYSQL_URL,
echo=False,
pool_size=10, # 백그라운드용 풀 크기: 10
max_overflow=10, # 추가 연결: 10 (총 최대 20)
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback",
connect_args={
"connect_timeout": 10,
"charset": "utf8mb4",
},
)
# 백그라운드 세션 팩토리
BackgroundSessionLocal = async_sessionmaker(
bind=background_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def create_db_tables():
import asyncio
# 모델 import (테이블 메타데이터 등록용)
from app.home.models import Image, Project # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song # noqa: F401
from app.video.models import Video # noqa: F401
print("Creating database tables...")
async with asyncio.timeout(10):
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
# FastAPI 의존성용 세션 제너레이터
async def get_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter()
pool = engine.pool
# 커넥션 풀 상태 로깅 (디버깅용)
print(
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
)
async with AsyncSessionLocal() as session:
acquire_time = time.perf_counter()
print(
f"[get_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
try:
yield session
except Exception as e:
await session.rollback()
print(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise e
finally:
total_time = time.perf_counter() - start_time
print(
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
)
# 백그라운드 태스크용 세션 제너레이터
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter()
pool = background_engine.pool
print(
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
)
async with BackgroundSessionLocal() as session:
acquire_time = time.perf_counter()
print(
f"[get_background_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
try:
yield session
except Exception as e:
await session.rollback()
print(
f"[get_background_session] ROLLBACK - "
f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise e
finally:
total_time = time.perf_counter() - start_time
print(
f"[get_background_session] RELEASE - "
f"duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
)
# 앱 종료 시 엔진 리소스 정리 함수
async def dispose_engine() -> None:
print("[dispose_engine] Disposing database engines...")
await engine.dispose()
print("[dispose_engine] Main engine disposed")
await background_engine.dispose()
print("[dispose_engine] Background engine disposed - ALL DONE")
import time
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from config import db_settings
class Base(DeclarativeBase):
pass
# =============================================================================
# 메인 엔진 (FastAPI 요청용)
# =============================================================================
engine = create_async_engine(
url=db_settings.MYSQL_URL,
echo=False,
pool_size=20, # 기본 풀 크기: 20
max_overflow=20, # 추가 연결: 20 (총 최대 40)
pool_timeout=30, # 풀에서 연결 대기 시간 (초)
pool_recycle=280, # MySQL wait_timeout(기본 28800s, 클라우드는 보통 300s) 보다 짧게 설정
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback", # 반환 시 롤백으로 초기화
connect_args={
"connect_timeout": 10, # DB 연결 타임아웃
"charset": "utf8mb4",
},
)
# 메인 세션 팩토리 (FastAPI DI용)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False, # 명시적 flush 권장
)
# =============================================================================
# 백그라운드 태스크 전용 엔진 (메인 풀과 분리)
# =============================================================================
background_engine = create_async_engine(
url=db_settings.MYSQL_URL,
echo=False,
pool_size=10, # 백그라운드용 풀 크기: 10
max_overflow=10, # 추가 연결: 10 (총 최대 20)
pool_timeout=60, # 백그라운드는 대기 시간 여유있게
pool_recycle=280, # MySQL wait_timeout 보다 짧게 설정
pool_pre_ping=True, # 연결 유효성 검사 (죽은 연결 자동 재연결)
pool_reset_on_return="rollback",
connect_args={
"connect_timeout": 10,
"charset": "utf8mb4",
},
)
# 백그라운드 세션 팩토리
BackgroundSessionLocal = async_sessionmaker(
bind=background_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def create_db_tables():
import asyncio
# 모델 import (테이블 메타데이터 등록용)
from app.home.models import Image, Project # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song # noqa: F401
from app.video.models import Video # noqa: F401
print("Creating database tables...")
async with asyncio.timeout(10):
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
# FastAPI 의존성용 세션 제너레이터
async def get_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter()
pool = engine.pool
# 커넥션 풀 상태 로깅 (디버깅용)
print(
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
)
async with AsyncSessionLocal() as session:
acquire_time = time.perf_counter()
print(
f"[get_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
try:
yield session
except Exception as e:
await session.rollback()
print(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise e
finally:
total_time = time.perf_counter() - start_time
print(
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
)
# 백그라운드 태스크용 세션 제너레이터
async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter()
pool = background_engine.pool
print(
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
)
async with BackgroundSessionLocal() as session:
acquire_time = time.perf_counter()
print(
f"[get_background_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
try:
yield session
except Exception as e:
await session.rollback()
print(
f"[get_background_session] ROLLBACK - "
f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise e
finally:
total_time = time.perf_counter() - start_time
print(
f"[get_background_session] RELEASE - "
f"duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
)
# 앱 종료 시 엔진 리소스 정리 함수
async def dispose_engine() -> None:
print("[dispose_engine] Disposing database engines...")
await engine.dispose()
print("[dispose_engine] Main engine disposed")
await background_engine.dispose()
print("[dispose_engine] Background engine disposed - ALL DONE")

BIN
app/home/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,102 +1,102 @@
from sqladmin import ModelView
from app.home.models import Image, Project
class ProjectAdmin(ModelView, model=Project):
name = "프로젝트"
name_plural = "프로젝트 목록"
icon = "fa-solid fa-folder"
category = "프로젝트 관리"
page_size = 20
column_list = [
"id",
"store_name",
"region",
"task_id",
"created_at",
]
column_details_list = [
"id",
"store_name",
"region",
"task_id",
"detail_region_info",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
column_searchable_list = [
Project.store_name,
Project.region,
Project.task_id,
]
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Project.id,
Project.store_name,
Project.region,
Project.created_at,
]
column_labels = {
"id": "ID",
"store_name": "가게명",
"region": "지역",
"task_id": "작업 ID",
"detail_region_info": "상세 지역 정보",
"created_at": "생성일시",
}
class ImageAdmin(ModelView, model=Image):
name = "이미지"
name_plural = "이미지 목록"
icon = "fa-solid fa-image"
category = "프로젝트 관리"
page_size = 20
column_list = [
"id",
"task_id",
"img_name",
"created_at",
]
column_details_list = [
"id",
"task_id",
"img_name",
"img_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at"]
column_searchable_list = [
Image.task_id,
Image.img_name,
]
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Image.id,
Image.img_name,
Image.created_at,
]
column_labels = {
"id": "ID",
"task_id": "작업 ID",
"img_name": "이미지명",
"img_url": "이미지 URL",
"created_at": "생성일시",
}
from sqladmin import ModelView
from app.home.models import Image, Project
class ProjectAdmin(ModelView, model=Project):
name = "프로젝트"
name_plural = "프로젝트 목록"
icon = "fa-solid fa-folder"
category = "프로젝트 관리"
page_size = 20
column_list = [
"id",
"store_name",
"region",
"task_id",
"created_at",
]
column_details_list = [
"id",
"store_name",
"region",
"task_id",
"detail_region_info",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "lyrics", "songs", "videos"]
column_searchable_list = [
Project.store_name,
Project.region,
Project.task_id,
]
column_default_sort = (Project.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Project.id,
Project.store_name,
Project.region,
Project.created_at,
]
column_labels = {
"id": "ID",
"store_name": "가게명",
"region": "지역",
"task_id": "작업 ID",
"detail_region_info": "상세 지역 정보",
"created_at": "생성일시",
}
class ImageAdmin(ModelView, model=Image):
name = "이미지"
name_plural = "이미지 목록"
icon = "fa-solid fa-image"
category = "프로젝트 관리"
page_size = 20
column_list = [
"id",
"task_id",
"img_name",
"created_at",
]
column_details_list = [
"id",
"task_id",
"img_name",
"img_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at"]
column_searchable_list = [
Image.task_id,
Image.img_name,
]
column_default_sort = (Image.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Image.id,
Image.img_name,
Image.created_at,
]
column_labels = {
"id": "ID",
"task_id": "작업 ID",
"img_name": "이미지명",
"img_url": "이미지 URL",
"created_at": "생성일시",
}

View File

@ -1,15 +1,15 @@
"""API 1 Version Router Module."""
# from fastapi import APIRouter, Depends
# API 버전 1 라우터를 정의합니다.
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router.include_router(auth.router, tags=[Tags.AUTH])
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])
"""API 1 Version Router Module."""
# from fastapi import APIRouter, Depends
# API 버전 1 라우터를 정의합니다.
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router.include_router(auth.router, tags=[Tags.AUTH])
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])

File diff suppressed because it is too large Load Diff

View File

@ -1,215 +1,215 @@
"""
Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리
"""
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.lyric.models import Lyric
from app.song.models import Song
from app.video.models import Video
class Project(Base):
"""
프로젝트 테이블 (사용자 입력 이력)
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
하위 테이블(Lyric, Song, Video) 부모 테이블 역할을 합니다.
Attributes:
id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정)
Relationships:
lyrics: 생성된 가사 목록
songs: 생성된 노래 목록
videos: 최종 영상 결과 목록
"""
__tablename__ = "project"
__table_args__ = (
Index("idx_project_task_id", "task_id"),
Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
store_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="가게명",
)
region: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="지역명 (예: 군산)",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)",
)
detail_region_info: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="상세 지역 정보",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
lyrics: Mapped[List["Lyric"]] = relationship(
"Lyric",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
songs: Mapped[List["Song"]] = relationship(
"Song",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Project("
f"id={self.id}, "
f"store_name='{self.store_name}', "
f"task_id='{truncate(self.task_id)}'"
f")>"
)
class Image(Base):
"""
업로드 이미지 테이블
사용자가 업로드한 이미지의 URL을 저장합니다.
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
Attributes:
id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정)
"""
__tablename__ = "image"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)",
)
img_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="이미지명",
)
img_url: Mapped[str] = mapped_column(
String(2048),
nullable=False,
comment="이미지 URL (blob, CDN 경로)",
)
img_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="이미지 순서",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
task_id_str = (
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
)
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)
"""
Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리
"""
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.lyric.models import Lyric
from app.song.models import Song
from app.video.models import Video
class Project(Base):
"""
프로젝트 테이블 (사용자 입력 이력)
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
하위 테이블(Lyric, Song, Video) 부모 테이블 역할을 합니다.
Attributes:
id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정)
Relationships:
lyrics: 생성된 가사 목록
songs: 생성된 노래 목록
videos: 최종 영상 결과 목록
"""
__tablename__ = "project"
__table_args__ = (
Index("idx_project_task_id", "task_id"),
Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
store_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="가게명",
)
region: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="지역명 (예: 군산)",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)",
)
detail_region_info: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="상세 지역 정보",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
lyrics: Mapped[List["Lyric"]] = relationship(
"Lyric",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
songs: Mapped[List["Song"]] = relationship(
"Song",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Project("
f"id={self.id}, "
f"store_name='{self.store_name}', "
f"task_id='{truncate(self.task_id)}'"
f")>"
)
class Image(Base):
"""
업로드 이미지 테이블
사용자가 업로드한 이미지의 URL을 저장합니다.
독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
Attributes:
id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정)
"""
__tablename__ = "image"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)",
)
img_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="이미지명",
)
img_url: Mapped[str] = mapped_column(
String(2048),
nullable=False,
comment="이미지 URL (blob, CDN 경로)",
)
img_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="이미지 순서",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
task_id_str = (
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
)
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)

View File

@ -1,260 +1,260 @@
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class AttributeInfo(BaseModel):
"""음악 속성 정보"""
genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
}
}
)
class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
],
}
}
)
images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1
)
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
}
}
)
url: str = Field(..., description="네이버 지도 장소 URL")
class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마"""
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
class MarketingAnalysis(BaseModel):
"""마케팅 분석 결과 스키마"""
report: str = Field(..., description="마케팅 분석 리포트")
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마"""
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
marketing_analysis: Optional[MarketingAnalysis] = Field(
None, description="마케팅 분석 결과 (report, tags, facilities)"
)
class ErrorResponse(BaseModel):
"""에러 응답 스키마"""
success: bool = Field(default=False, description="요청 성공 여부")
error_code: str = Field(..., description="에러 코드")
message: str = Field(..., description="에러 메시지")
detail: Optional[str] = Field(None, description="상세 에러 정보")
# =============================================================================
# Image Upload Schemas
# =============================================================================
class ImageUrlItem(BaseModel):
"""이미지 URL 아이템 스키마"""
url: str = Field(..., description="외부 이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadRequest(BaseModel):
"""이미지 업로드 요청 스키마 (JSON body 부분)
URL 이미지 목록을 전달합니다.
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
]
}
}
)
images: Optional[list[ImageUrlItem]] = Field(
None, description="외부 이미지 URL 목록"
)
class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템"""
id: int = Field(..., description="이미지 ID")
img_name: str = Field(..., description="이미지명")
img_url: str = Field(..., description="이미지 URL")
img_order: int = Field(..., description="이미지 순서")
source: Literal["url", "file", "blob"] = Field(
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
)
class ImageUploadResponse(BaseModel):
"""이미지 업로드 응답 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"total_count": 3,
"url_count": 2,
"file_count": 1,
"saved_count": 3,
"images": [
{
"id": 1,
"img_name": "외관",
"img_url": "https://example.com/images/image_001.jpg",
"img_order": 0,
"source": "url",
},
{
"id": 2,
"img_name": "내부",
"img_url": "https://example.com/images/image_002.jpg",
"img_order": 1,
"source": "url",
},
{
"id": 3,
"img_name": "uploaded_image.jpg",
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
"img_order": 2,
"source": "file",
},
],
"image_urls": [
"https://example.com/images/image_001.jpg",
"https://example.com/images/image_002.jpg",
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
],
}
}
)
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class AttributeInfo(BaseModel):
"""음악 속성 정보"""
genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
}
}
)
class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
],
}
}
)
images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1
)
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"url": "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
}
}
)
url: str = Field(..., description="네이버 지도 장소 URL")
class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마"""
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
class MarketingAnalysis(BaseModel):
"""마케팅 분석 결과 스키마"""
report: str = Field(..., description="마케팅 분석 리포트")
tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마"""
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
)
marketing_analysis: Optional[MarketingAnalysis] = Field(
None, description="마케팅 분석 결과 (report, tags, facilities)"
)
class ErrorResponse(BaseModel):
"""에러 응답 스키마"""
success: bool = Field(default=False, description="요청 성공 여부")
error_code: str = Field(..., description="에러 코드")
message: str = Field(..., description="에러 메시지")
detail: Optional[str] = Field(None, description="상세 에러 정보")
# =============================================================================
# Image Upload Schemas
# =============================================================================
class ImageUrlItem(BaseModel):
"""이미지 URL 아이템 스키마"""
url: str = Field(..., description="외부 이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadRequest(BaseModel):
"""이미지 업로드 요청 스키마 (JSON body 부분)
URL 이미지 목록을 전달합니다.
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
]
}
}
)
images: Optional[list[ImageUrlItem]] = Field(
None, description="외부 이미지 URL 목록"
)
class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템"""
id: int = Field(..., description="이미지 ID")
img_name: str = Field(..., description="이미지명")
img_url: str = Field(..., description="이미지 URL")
img_order: int = Field(..., description="이미지 순서")
source: Literal["url", "file", "blob"] = Field(
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
)
class ImageUploadResponse(BaseModel):
"""이미지 업로드 응답 스키마"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"total_count": 3,
"url_count": 2,
"file_count": 1,
"saved_count": 3,
"images": [
{
"id": 1,
"img_name": "외관",
"img_url": "https://example.com/images/image_001.jpg",
"img_order": 0,
"source": "url",
},
{
"id": 2,
"img_name": "내부",
"img_url": "https://example.com/images/image_002.jpg",
"img_order": 1,
"source": "url",
},
{
"id": 3,
"img_name": "uploaded_image.jpg",
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
"img_order": 2,
"source": "file",
},
],
"image_urls": [
"https://example.com/images/image_001.jpg",
"https://example.com/images/image_002.jpg",
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
],
}
}
)
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")

View File

@ -1,24 +1,24 @@
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
await self.session.delete(entity)

View File

@ -1,48 +1,48 @@
from typing import AsyncGenerator
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.database.session import Base
from config import db_settings
# 테스트 전용 DB URL
TEST_DB_URL = db_settings.MYSQL_URL.replace(
f"/{db_settings.MYSQL_DB}",
"/test_db", # 별도 테스트 DB 사용
)
@pytest_asyncio.fixture
async def test_engine():
"""각 테스트마다 생성되는 테스트 엔진"""
engine = create_async_engine(
TEST_DB_URL,
poolclass=NullPool, # 테스트에서는 풀 비활성화
echo=True, # SQL 쿼리 로깅
)
# 테스트 테이블 생성
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# 테스트 테이블 삭제
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""각 테스트마다 새로운 세션 (격리 보장)"""
async_session = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback() # 테스트 후 롤백
from typing import AsyncGenerator
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.database.session import Base
from config import db_settings
# 테스트 전용 DB URL
TEST_DB_URL = db_settings.MYSQL_URL.replace(
f"/{db_settings.MYSQL_DB}",
"/test_db", # 별도 테스트 DB 사용
)
@pytest_asyncio.fixture
async def test_engine():
"""각 테스트마다 생성되는 테스트 엔진"""
engine = create_async_engine(
TEST_DB_URL,
poolclass=NullPool, # 테스트에서는 풀 비활성화
echo=True, # SQL 쿼리 로깅
)
# 테스트 테이블 생성
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
# 테스트 테이블 삭제
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""각 테스트마다 새로운 세션 (격리 보장)"""
async_session = async_sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
await session.rollback() # 테스트 후 롤백

View File

@ -1,17 +1,17 @@
import pytest
from sqlalchemy import text
@pytest.mark.asyncio
async def test_database_connection(test_engine):
"""테스트 엔진을 사용한 연결 테스트"""
async with test_engine.begin() as connection:
result = await connection.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_session_usage(db_session):
"""세션을 사용한 테스트"""
result = await db_session.execute(text("SELECT 1 as num"))
assert result.scalar() == 1
import pytest
from sqlalchemy import text
@pytest.mark.asyncio
async def test_database_connection(test_engine):
"""테스트 엔진을 사용한 연결 테스트"""
async with test_engine.begin() as connection:
result = await connection.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_session_usage(db_session):
"""세션을 사용한 테스트"""
result = await db_session.execute(text("SELECT 1 as num"))
assert result.scalar() == 1

View File

@ -1,30 +1,30 @@
import pytest
from sqlalchemy import text
from app.database.session import AsyncSessionLocal, engine
@pytest.mark.asyncio
async def test_database_connection():
"""데이터베이스 연결 테스트"""
async with engine.begin() as connection:
result = await connection.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_session_creation():
"""세션 생성 테스트"""
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_database_version():
"""MySQL 버전 확인 테스트"""
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT VERSION()"))
version = result.scalar()
assert version is not None
print(f"MySQL Version: {version}")
import pytest
from sqlalchemy import text
from app.database.session import AsyncSessionLocal, engine
@pytest.mark.asyncio
async def test_database_connection():
"""데이터베이스 연결 테스트"""
async with engine.begin() as connection:
result = await connection.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_session_creation():
"""세션 생성 테스트"""
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT 1"))
assert result.scalar() == 1
@pytest.mark.asyncio
async def test_database_version():
"""MySQL 버전 확인 테스트"""
async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT VERSION()"))
version = result.scalar()
assert version is not None
print(f"MySQL Version: {version}")

BIN
app/lyric/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -1,61 +1,61 @@
from sqladmin import ModelView
from app.lyric.models import Lyric
class LyricAdmin(ModelView, model=Lyric):
name = "가사"
name_plural = "가사 목록"
icon = "fa-solid fa-music"
category = "가사 관리"
page_size = 20
column_list = [
"id",
"project_id",
"task_id",
"status",
"language",
"created_at",
]
column_details_list = [
"id",
"project_id",
"task_id",
"status",
"language",
"lyric_prompt",
"lyric_result",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "songs", "videos"]
column_searchable_list = [
Lyric.task_id,
Lyric.status,
Lyric.language,
]
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Lyric.id,
Lyric.project_id,
Lyric.status,
Lyric.language,
Lyric.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"task_id": "작업 ID",
"status": "상태",
"language": "언어",
"lyric_prompt": "프롬프트",
"lyric_result": "생성 결과",
"created_at": "생성일시",
}
from sqladmin import ModelView
from app.lyric.models import Lyric
class LyricAdmin(ModelView, model=Lyric):
name = "가사"
name_plural = "가사 목록"
icon = "fa-solid fa-music"
category = "가사 관리"
page_size = 20
column_list = [
"id",
"project_id",
"task_id",
"status",
"language",
"created_at",
]
column_details_list = [
"id",
"project_id",
"task_id",
"status",
"language",
"lyric_prompt",
"lyric_result",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "songs", "videos"]
column_searchable_list = [
Lyric.task_id,
Lyric.status,
Lyric.language,
]
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Lyric.id,
Lyric.project_id,
Lyric.status,
Lyric.language,
Lyric.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"task_id": "작업 ID",
"status": "상태",
"language": "언어",
"lyric_prompt": "프롬프트",
"lyric_result": "생성 결과",
"created_at": "생성일시",
}

View File

@ -1,417 +1,448 @@
"""
Lyric API Router
모듈은 가사 관련 API 엔드포인트를 정의합니다.
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
엔드포인트 목록:
- POST /lyric/generate: 가사 생성
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
- GET /lyric/{task_id}: 가사 상세 조회
- GET /lyrics: 가사 목록 조회 (페이지네이션)
사용 예시:
from app.lyric.api.routers.v1.lyric import router
app.include_router(router, prefix="/api/v1")
다른 서비스에서 재사용:
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
from app.lyric.api.routers.v1.lyric import (
get_lyric_status_by_task_id,
get_lyric_by_task_id,
)
# 페이지네이션은 pagination 모듈 사용
from app.utils.pagination import PaginatedResponse, get_paginated
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.home.models import Project
from app.lyric.models import Lyric
from app.lyric.schemas.lyric import (
GenerateLyricRequest,
GenerateLyricResponse,
LyricDetailResponse,
LyricListItem,
LyricStatusResponse,
)
from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.pagination import PaginatedResponse, get_paginated
router = APIRouter(prefix="/lyric", tags=["lyric"])
# =============================================================================
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
# =============================================================================
async def get_lyric_status_by_task_id(
session: AsyncSession, task_id: str
) -> LyricStatusResponse:
"""task_id로 가사 생성 작업의 상태를 조회합니다.
Args:
session: SQLAlchemy AsyncSession
task_id: 작업 고유 식별자
Returns:
LyricStatusResponse: 상태 정보
Raises:
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
Usage:
# 다른 서비스에서 사용
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
if status_info.status == "completed":
# 완료 처리
"""
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
)
status_messages = {
"processing": "가사 생성 중입니다.",
"completed": "가사 생성이 완료되었습니다.",
"failed": "가사 생성에 실패했습니다.",
}
print(
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
)
return LyricStatusResponse(
task_id=lyric.task_id,
status=lyric.status,
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
)
async def get_lyric_by_task_id(
session: AsyncSession, task_id: str
) -> LyricDetailResponse:
"""task_id로 생성된 가사 상세 정보를 조회합니다.
Args:
session: SQLAlchemy AsyncSession
task_id: 작업 고유 식별자
Returns:
LyricDetailResponse: 가사 상세 정보
Raises:
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
Usage:
# 다른 서비스에서 사용
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
lyric = await get_lyric_by_task_id(session, task_id)
"""
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
)
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
return LyricDetailResponse(
id=lyric.id,
task_id=lyric.task_id,
project_id=lyric.project_id,
status=lyric.status,
lyric_prompt=lyric.lyric_prompt,
lyric_result=lyric.lyric_result,
created_at=lyric.created_at,
)
# =============================================================================
# API Endpoints
# =============================================================================
@router.post(
"/generate",
summary="가사 생성",
description="""
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
- **customer_name**: 고객명/가게명 (필수)
- **region**: 지역명 (필수)
- **detail_region_info**: 상세 지역 정보 (선택)
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
## 반환 정보
- **success**: 요청 접수 성공 여부
- **task_id**: 작업 고유 식별자
- **lyric**: null (백그라운드 처리 )
- **language**: 가사 언어
- **error_message**: 에러 메시지 (요청 접수 실패 )
## 상태 확인
- GET /lyric/status/{task_id} 처리 상태 확인
- GET /lyric/{task_id} 생성된 가사 조회
## 사용 예시
```
POST /lyric/generate
{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
```
## 응답 예시
```json
{
"success": true,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"lyric": null,
"language": "Korean",
"error_message": null
}
```
""",
response_model=GenerateLyricResponse,
responses={
200: {"description": "가사 생성 요청 접수 성공"},
500: {"description": "서버 내부 오류"},
},
)
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
task_id = request_body.task_id
print(
f"[generate_lyric] START - task_id: {task_id}, "
f"customer_name: {request_body.customer_name}, "
f"region: {request_body.region}"
)
try:
# 1. ChatGPT 서비스 초기화 및 프롬프트 생성
service = ChatgptService(
customer_name=request_body.customer_name,
region=request_body.region,
detail_region_info=request_body.detail_region_info or "",
language=request_body.language,
)
prompt = service.build_lyrics_prompt()
# 2. Project 테이블에 데이터 저장
project = Project(
store_name=request_body.customer_name,
region=request_body.region,
task_id=task_id,
detail_region_info=request_body.detail_region_info,
language=request_body.language,
)
session.add(project)
await session.commit()
await session.refresh(project)
print(
f"[generate_lyric] Project saved - "
f"project_id: {project.id}, task_id: {task_id}"
)
# 3. Lyric 테이블에 데이터 저장 (status: processing)
lyric = Lyric(
project_id=project.id,
task_id=task_id,
status="processing",
lyric_prompt=prompt,
lyric_result=None,
language=request_body.language,
)
session.add(lyric)
await session.commit()
await session.refresh(lyric)
print(
f"[generate_lyric] Lyric saved (processing) - "
f"lyric_id: {lyric.id}, task_id: {task_id}"
)
# 4. 백그라운드 태스크로 ChatGPT 가사 생성 실행
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=prompt,
language=request_body.language,
)
print(f"[generate_lyric] Background task scheduled - task_id: {task_id}")
# 5. 즉시 응답 반환
return GenerateLyricResponse(
success=True,
task_id=task_id,
lyric=None,
language=request_body.language,
error_message=None,
)
except Exception as e:
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e}")
await session.rollback()
return GenerateLyricResponse(
success=False,
task_id=task_id,
lyric=None,
language=request_body.language,
error_message=str(e),
)
@router.get(
"/status/{task_id}",
summary="가사 생성 상태 조회",
description="""
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
## 상태 값
- **processing**: 가사 생성
- **completed**: 가사 생성 완료
- **failed**: 가사 생성 실패
## 사용 예시
```
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
```
""",
response_model=LyricStatusResponse,
responses={
200: {"description": "상태 조회 성공"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_status(
task_id: str,
session: AsyncSession = Depends(get_session),
) -> LyricStatusResponse:
"""task_id로 가사 생성 작업 상태를 조회합니다."""
return await get_lyric_status_by_task_id(session, task_id)
@router.get(
"s",
summary="가사 목록 조회 (페이지네이션)",
description="""
생성 완료된 가사를 페이지네이션으로 조회합니다.
## 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100)
## 반환 정보
- **items**: 가사 목록 (completed 상태만)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /lyrics # 기본 조회 (1페이지, 20개)
GET /lyrics?page=2 # 2페이지 조회
GET /lyrics?page=1&page_size=50 # 50개씩 조회
```
## 참고
- 생성 완료(completed) 가사만 조회됩니다.
- processing, failed 상태의 가사는 조회되지 않습니다.
""",
response_model=PaginatedResponse[LyricListItem],
responses={
200: {"description": "가사 목록 조회 성공"},
},
)
async def list_lyrics(
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
session: AsyncSession = Depends(get_session),
) -> PaginatedResponse[LyricListItem]:
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
return await get_paginated(
session=session,
model=Lyric,
item_schema=LyricListItem,
page=page,
page_size=page_size,
filters={"status": "completed"},
order_by="created_at",
order_desc=True,
)
@router.get(
"/{task_id}",
summary="가사 상세 조회",
description="""
task_id로 생성된 가사의 상세 정보를 조회합니다.
## 반환 정보
- **id**: 가사 ID
- **task_id**: 작업 고유 식별자
- **project_id**: 프로젝트 ID
- **status**: 처리 상태
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
- **lyric_result**: 생성된 가사 (완료 )
- **created_at**: 생성 일시
## 사용 예시
```
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
```
""",
response_model=LyricDetailResponse,
responses={
200: {"description": "가사 조회 성공"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_detail(
task_id: str,
session: AsyncSession = Depends(get_session),
) -> LyricDetailResponse:
"""task_id로 생성된 가사를 조회합니다."""
return await get_lyric_by_task_id(session, task_id)
"""
Lyric API Router
모듈은 가사 관련 API 엔드포인트를 정의합니다.
모든 엔드포인트는 재사용 가능하도록 설계되었습니다.
엔드포인트 목록:
- POST /lyric/generate: 가사 생성
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
- GET /lyric/{task_id}: 가사 상세 조회
- GET /lyrics: 가사 목록 조회 (페이지네이션)
사용 예시:
from app.lyric.api.routers.v1.lyric import router
app.include_router(router, prefix="/api/v1")
다른 서비스에서 재사용:
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
from app.lyric.api.routers.v1.lyric import (
get_lyric_status_by_task_id,
get_lyric_by_task_id,
)
# 페이지네이션은 pagination 모듈 사용
from app.utils.pagination import PaginatedResponse, get_paginated
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.home.models import Project
from app.lyric.models import Lyric
from app.lyric.schemas.lyric import (
GenerateLyricRequest,
GenerateLyricResponse,
LyricDetailResponse,
LyricListItem,
LyricStatusResponse,
)
from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.pagination import PaginatedResponse, get_paginated
router = APIRouter(prefix="/lyric", tags=["lyric"])
# =============================================================================
# Reusable Service Functions (다른 모듈에서 import하여 사용 가능)
# =============================================================================
async def get_lyric_status_by_task_id(
session: AsyncSession, task_id: str
) -> LyricStatusResponse:
"""task_id로 가사 생성 작업의 상태를 조회합니다.
Args:
session: SQLAlchemy AsyncSession
task_id: 작업 고유 식별자
Returns:
LyricStatusResponse: 상태 정보
Raises:
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
Usage:
# 다른 서비스에서 사용
from app.lyric.api.routers.v1.lyric import get_lyric_status_by_task_id
status_info = await get_lyric_status_by_task_id(session, "some-task-id")
if status_info.status == "completed":
# 완료 처리
"""
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
)
status_messages = {
"processing": "가사 생성 중입니다.",
"completed": "가사 생성이 완료되었습니다.",
"failed": "가사 생성에 실패했습니다.",
}
print(
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
)
return LyricStatusResponse(
task_id=lyric.task_id,
status=lyric.status,
message=status_messages.get(lyric.status, "알 수 없는 상태입니다."),
)
async def get_lyric_by_task_id(
session: AsyncSession, task_id: str
) -> LyricDetailResponse:
"""task_id로 생성된 가사 상세 정보를 조회합니다.
Args:
session: SQLAlchemy AsyncSession
task_id: 작업 고유 식별자
Returns:
LyricDetailResponse: 가사 상세 정보
Raises:
HTTPException: 404 - task_id에 해당하는 가사가 없는 경우
Usage:
# 다른 서비스에서 사용
from app.lyric.api.routers.v1.lyric import get_lyric_by_task_id
lyric = await get_lyric_by_task_id(session, task_id)
"""
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
)
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
return LyricDetailResponse(
id=lyric.id,
task_id=lyric.task_id,
project_id=lyric.project_id,
status=lyric.status,
lyric_prompt=lyric.lyric_prompt,
lyric_result=lyric.lyric_result,
created_at=lyric.created_at,
)
# =============================================================================
# API Endpoints
# =============================================================================
@router.post(
"/generate",
summary="가사 생성",
description="""
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
- **customer_name**: 고객명/가게명 (필수)
- **region**: 지역명 (필수)
- **detail_region_info**: 상세 지역 정보 (선택)
- **language**: 가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)
## 반환 정보
- **success**: 요청 접수 성공 여부
- **task_id**: 작업 고유 식별자
- **lyric**: null (백그라운드 처리 )
- **language**: 가사 언어
- **error_message**: 에러 메시지 (요청 접수 실패 )
## 상태 확인
- GET /lyric/status/{task_id} 처리 상태 확인
- GET /lyric/{task_id} 생성된 가사 조회
## 사용 예시
```
POST /lyric/generate
{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
```
## 응답 예시
```json
{
"success": true,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"lyric": null,
"language": "Korean",
"error_message": null
}
```
""",
response_model=GenerateLyricResponse,
responses={
200: {"description": "가사 생성 요청 접수 성공"},
500: {"description": "서버 내부 오류"},
},
)
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
import time
request_start = time.perf_counter()
task_id = request_body.task_id
print(f"[generate_lyric] ========== START ==========")
print(
f"[generate_lyric] task_id: {task_id}, "
f"customer_name: {request_body.customer_name}, "
f"region: {request_body.region}"
)
try:
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
service = ChatgptService(
customer_name=request_body.customer_name,
region=request_body.region,
detail_region_info=request_body.detail_region_info or "",
language=request_body.language,
)
prompt = service.build_lyrics_prompt()
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 테이블에 데이터 저장 ==========
step2_start = time.perf_counter()
print(f"[generate_lyric] Step 2: Project 저장...")
project = Project(
store_name=request_body.customer_name,
region=request_body.region,
task_id=task_id,
detail_region_info=request_body.detail_region_info,
language=request_body.language,
)
session.add(project)
await session.commit()
await session.refresh(project)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
lyric = Lyric(
project_id=project.id,
task_id=task_id,
status="processing",
lyric_prompt=prompt,
lyric_result=None,
language=request_body.language,
)
session.add(lyric)
await session.commit()
await session.refresh(lyric)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter()
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=prompt,
language=request_body.language,
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] ========== COMPLETE ==========")
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
# 5. 즉시 응답 반환
return GenerateLyricResponse(
success=True,
task_id=task_id,
lyric=None,
language=request_body.language,
error_message=None,
)
except Exception as e:
elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
await session.rollback()
return GenerateLyricResponse(
success=False,
task_id=task_id,
lyric=None,
language=request_body.language,
error_message=str(e),
)
@router.get(
"/status/{task_id}",
summary="가사 생성 상태 조회",
description="""
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
## 상태 값
- **processing**: 가사 생성
- **completed**: 가사 생성 완료
- **failed**: 가사 생성 실패
## 사용 예시
```
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
```
""",
response_model=LyricStatusResponse,
responses={
200: {"description": "상태 조회 성공"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_status(
task_id: str,
session: AsyncSession = Depends(get_session),
) -> LyricStatusResponse:
"""task_id로 가사 생성 작업 상태를 조회합니다."""
return await get_lyric_status_by_task_id(session, task_id)
@router.get(
"s",
summary="가사 목록 조회 (페이지네이션)",
description="""
생성 완료된 가사를 페이지네이션으로 조회합니다.
## 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100)
## 반환 정보
- **items**: 가사 목록 (completed 상태만)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /lyrics # 기본 조회 (1페이지, 20개)
GET /lyrics?page=2 # 2페이지 조회
GET /lyrics?page=1&page_size=50 # 50개씩 조회
```
## 참고
- 생성 완료(completed) 가사만 조회됩니다.
- processing, failed 상태의 가사는 조회되지 않습니다.
""",
response_model=PaginatedResponse[LyricListItem],
responses={
200: {"description": "가사 목록 조회 성공"},
},
)
async def list_lyrics(
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
session: AsyncSession = Depends(get_session),
) -> PaginatedResponse[LyricListItem]:
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
return await get_paginated(
session=session,
model=Lyric,
item_schema=LyricListItem,
page=page,
page_size=page_size,
filters={"status": "completed"},
order_by="created_at",
order_desc=True,
)
@router.get(
"/{task_id}",
summary="가사 상세 조회",
description="""
task_id로 생성된 가사의 상세 정보를 조회합니다.
## 반환 정보
- **id**: 가사 ID
- **task_id**: 작업 고유 식별자
- **project_id**: 프로젝트 ID
- **status**: 처리 상태
- **lyric_prompt**: 가사 생성에 사용된 프롬프트
- **lyric_result**: 생성된 가사 (완료 )
- **created_at**: 생성 일시
## 사용 예시
```
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
```
""",
response_model=LyricDetailResponse,
responses={
200: {"description": "가사 조회 성공"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_detail(
task_id: str,
session: AsyncSession = Depends(get_session),
) -> LyricDetailResponse:
"""task_id로 생성된 가사를 조회합니다."""
return await get_lyric_by_task_id(session, task_id)

View File

@ -1,8 +1,8 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -1,133 +1,133 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.song.models import Song
from app.video.models import Video
class Lyric(Base):
"""
가사 테이블
AI를 통해 생성된 가사 정보를 저장합니다.
프롬프트와 생성 결과, 처리 상태를 관리합니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
songs: 가사를 사용한 노래 목록
videos: 가사를 사용한 영상 목록
"""
__tablename__ = "lyric"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID)",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
lyric_prompt: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="가사 생성에 사용된 프롬프트",
)
lyric_result: Mapped[str] = mapped_column(
LONGTEXT,
nullable=True,
comment="생성된 가사 결과",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="lyrics",
)
songs: Mapped[List["Song"]] = relationship(
"Song",
back_populates="lyric",
cascade="all, delete-orphan",
lazy="selectin",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="lyric",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Lyric("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.song.models import Song
from app.video.models import Video
class Lyric(Base):
"""
가사 테이블
AI를 통해 생성된 가사 정보를 저장합니다.
프롬프트와 생성 결과, 처리 상태를 관리합니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
songs: 가사를 사용한 노래 목록
videos: 가사를 사용한 영상 목록
"""
__tablename__ = "lyric"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID)",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
lyric_prompt: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="가사 생성에 사용된 프롬프트",
)
lyric_result: Mapped[str] = mapped_column(
LONGTEXT,
nullable=True,
comment="생성된 가사 결과",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="lyrics",
)
songs: Mapped[List["Song"]] = relationship(
"Song",
back_populates="lyric",
cascade="all, delete-orphan",
lazy="selectin",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="lyric",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Lyric("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)

View File

@ -1,182 +1,182 @@
"""
Lyric API Schemas
모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
사용 예시:
from app.lyric.schemas.lyric import (
LyricStatusResponse,
LyricDetailResponse,
LyricListItem,
)
from app.utils.pagination import PaginatedResponse
# 라우터에서 response_model로 사용
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
async def get_lyric(task_id: str):
...
# 페이지네이션 응답 (공통 스키마 사용)
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
async def list_songs(...):
...
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class GenerateLyricRequest(BaseModel):
"""가사 생성 요청 스키마
Usage:
POST /lyric/generate
Request body for generating lyrics.
Example Request:
{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
}
}
)
task_id: str = Field(
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
)
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateLyricResponse(BaseModel):
"""가사 생성 응답 스키마
Usage:
POST /lyric/generate
Returns the generated lyrics.
Note:
실패 조건:
- ChatGPT API 오류
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize )
- 응답에 ERROR: 포함
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"language": "Korean",
"error_message": None,
}
}
)
success: bool = Field(..., description="생성 성공 여부")
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
language: str = Field(..., description="가사 언어")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
class LyricStatusResponse(BaseModel):
"""가사 상태 조회 응답 스키마
Usage:
GET /lyric/status/{task_id}
Returns the current processing status of a lyric generation task.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"status": "completed",
"message": "가사 생성이 완료되었습니다.",
}
}
)
task_id: str = Field(..., description="작업 고유 식별자")
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
message: str = Field(..., description="상태 메시지")
class LyricDetailResponse(BaseModel):
"""가사 상세 조회 응답 스키마
Usage:
GET /lyric/{task_id}
Returns the generated lyric content for a specific task.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"project_id": 1,
"status": "completed",
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"created_at": "2024-01-15T12:00:00",
}
}
)
id: int = Field(..., description="가사 ID")
task_id: str = Field(..., description="작업 고유 식별자")
project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태")
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
lyric_result: Optional[str] = Field(None, description="생성된 가사")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class LyricListItem(BaseModel):
"""가사 목록 아이템 스키마
Usage:
Used as individual items in paginated lyric list responses.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"status": "completed",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
"created_at": "2024-01-15T12:00:00",
}
}
)
id: int = Field(..., description="가사 ID")
task_id: str = Field(..., description="작업 고유 식별자")
status: str = Field(..., description="처리 상태")
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
created_at: Optional[datetime] = Field(None, description="생성 일시")
"""
Lyric API Schemas
모듈은 가사 관련 API 엔드포인트에서 사용되는 Pydantic 스키마를 정의합니다.
사용 예시:
from app.lyric.schemas.lyric import (
LyricStatusResponse,
LyricDetailResponse,
LyricListItem,
)
from app.utils.pagination import PaginatedResponse
# 라우터에서 response_model로 사용
@router.get("/lyric/{task_id}", response_model=LyricDetailResponse)
async def get_lyric(task_id: str):
...
# 페이지네이션 응답 (공통 스키마 사용)
@router.get("/songs", response_model=PaginatedResponse[SongListItem])
async def list_songs(...):
...
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class GenerateLyricRequest(BaseModel):
"""가사 생성 요청 스키마
Usage:
POST /lyric/generate
Request body for generating lyrics.
Example Request:
{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
}
}
)
task_id: str = Field(
..., description="작업 고유 식별자 (이미지 업로드 시 생성된 task_id)"
)
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateLyricResponse(BaseModel):
"""가사 생성 응답 스키마
Usage:
POST /lyric/generate
Returns the generated lyrics.
Note:
실패 조건:
- ChatGPT API 오류
- ChatGPT 거부 응답 (I'm sorry, I cannot, I can't, I apologize )
- 응답에 ERROR: 포함
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"lyric": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"language": "Korean",
"error_message": None,
}
}
)
success: bool = Field(..., description="생성 성공 여부")
task_id: Optional[str] = Field(None, description="작업 고유 식별자 (uuid7)")
lyric: Optional[str] = Field(None, description="생성된 가사 (성공 시)")
language: str = Field(..., description="가사 언어")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시, ChatGPT 거부 응답 포함)")
class LyricStatusResponse(BaseModel):
"""가사 상태 조회 응답 스키마
Usage:
GET /lyric/status/{task_id}
Returns the current processing status of a lyric generation task.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"status": "completed",
"message": "가사 생성이 완료되었습니다.",
}
}
)
task_id: str = Field(..., description="작업 고유 식별자")
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
message: str = Field(..., description="상태 메시지")
class LyricDetailResponse(BaseModel):
"""가사 상세 조회 응답 스키마
Usage:
GET /lyric/{task_id}
Returns the generated lyric content for a specific task.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"project_id": 1,
"status": "completed",
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"created_at": "2024-01-15T12:00:00",
}
}
)
id: int = Field(..., description="가사 ID")
task_id: str = Field(..., description="작업 고유 식별자")
project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태")
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
lyric_result: Optional[str] = Field(None, description="생성된 가사")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class LyricListItem(BaseModel):
"""가사 목록 아이템 스키마
Usage:
Used as individual items in paginated lyric list responses.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": 1,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"status": "completed",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서...",
"created_at": "2024-01-15T12:00:00",
}
}
)
id: int = Field(..., description="가사 ID")
task_id: str = Field(..., description="작업 고유 식별자")
status: str = Field(..., description="처리 상태")
lyric_result: Optional[str] = Field(None, description="생성된 가사 (미리보기)")
created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

@ -1,91 +1,91 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List
from fastapi import Request
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-4o"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-4o"),
prompts=form_data.get("prompts", ""),
)
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List
from fastapi import Request
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -1,24 +1,24 @@
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
await self.session.delete(entity)

File diff suppressed because it is too large Load Diff

View File

@ -1,98 +1,146 @@
"""
Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다.
"""
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService
async def generate_lyric_background(
task_id: str,
prompt: str,
language: str,
) -> None:
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
prompt: ChatGPT에 전달할 프롬프트
language: 가사 언어
"""
print(f"[generate_lyric_background] START - task_id: {task_id}")
try:
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음)
service = ChatgptService(
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
region="",
detail_region_info="",
language=language,
)
# ChatGPT를 통해 가사 생성
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}")
result = await service.generate(prompt=prompt)
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}")
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답)
failure_patterns = [
"ERROR:",
"I'm sorry",
"I cannot",
"I can't",
"I apologize",
"I'm unable",
"I am unable",
"I'm not able",
"I am not able",
]
is_failure = any(
pattern.lower() in result.lower() for pattern in failure_patterns
)
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
if is_failure:
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}")
lyric.status = "failed"
lyric.lyric_result = result
else:
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}")
lyric.status = "completed"
lyric.lyric_result = result
await session.commit()
else:
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}")
except Exception as e:
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Lyric 테이블 업데이트
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
lyric.status = "failed"
lyric.lyric_result = f"Error: {str(e)}"
await session.commit()
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed")
"""
Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService
# 로거 설정
logger = logging.getLogger(__name__)
async def _update_lyric_status(
task_id: str,
status: str,
result: str | None = None,
) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
lyric.status = status
if result is not None:
lyric.lyric_result = result
await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
async def generate_lyric_background(
task_id: str,
prompt: str,
language: str,
) -> None:
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
prompt: ChatGPT에 전달할 프롬프트
language: 가사 언어
"""
import time
task_start = time.perf_counter()
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
print(f"[generate_lyric_background] ========== START ==========")
print(f"[generate_lyric_background] task_id: {task_id}")
print(f"[generate_lyric_background] language: {language}")
print(f"[generate_lyric_background] prompt length: {len(prompt)}")
try:
# ========== Step 1: ChatGPT 서비스 초기화 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
service = ChatgptService(
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
region="",
detail_region_info="",
language=language,
)
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
# ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
step2_start = time.perf_counter()
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
result = await service.generate(prompt=prompt)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
# ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - task_start) * 1000
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] ========== SUCCESS ==========")
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")

BIN
app/song/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,69 +1,69 @@
from sqladmin import ModelView
from app.song.models import Song
class SongAdmin(ModelView, model=Song):
name = "노래"
name_plural = "노래 목록"
icon = "fa-solid fa-headphones"
category = "노래 관리"
page_size = 20
column_list = [
"id",
"project_id",
"lyric_id",
"task_id",
"suno_task_id",
"status",
"language",
"created_at",
]
column_details_list = [
"id",
"project_id",
"lyric_id",
"task_id",
"suno_task_id",
"status",
"language",
"song_prompt",
"song_result_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "videos"]
column_searchable_list = [
Song.task_id,
Song.suno_task_id,
Song.status,
Song.language,
]
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Song.id,
Song.project_id,
Song.lyric_id,
Song.status,
Song.language,
Song.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"lyric_id": "가사 ID",
"task_id": "작업 ID",
"suno_task_id": "Suno 작업 ID",
"status": "상태",
"language": "언어",
"song_prompt": "프롬프트",
"song_result_url": "결과 URL",
"created_at": "생성일시",
}
from sqladmin import ModelView
from app.song.models import Song
class SongAdmin(ModelView, model=Song):
name = "노래"
name_plural = "노래 목록"
icon = "fa-solid fa-headphones"
category = "노래 관리"
page_size = 20
column_list = [
"id",
"project_id",
"lyric_id",
"task_id",
"suno_task_id",
"status",
"language",
"created_at",
]
column_details_list = [
"id",
"project_id",
"lyric_id",
"task_id",
"suno_task_id",
"status",
"language",
"song_prompt",
"song_result_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "videos"]
column_searchable_list = [
Song.task_id,
Song.suno_task_id,
Song.status,
Song.language,
]
column_default_sort = (Song.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Song.id,
Song.project_id,
Song.lyric_id,
Song.status,
Song.language,
Song.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"lyric_id": "가사 ID",
"task_id": "작업 ID",
"suno_task_id": "Suno 작업 ID",
"status": "상태",
"language": "언어",
"song_prompt": "프롬프트",
"song_result_url": "결과 URL",
"created_at": "생성일시",
}

View File

@ -1,8 +1,8 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -1,152 +1,152 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.lyric.models import Lyric
from app.video.models import Video
class Song(Base):
"""
노래 테이블
AI를 통해 생성된 노래 정보를 저장합니다.
가사를 기반으로 생성됩니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (pending, processing, completed, failed )
song_prompt: 노래 생성에 사용된 프롬프트
song_result_url: 생성 결과 URL (선택)
language: 출력 언어
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
lyric: 연결된 Lyric
videos: 노래를 사용한 영상 결과 목록
"""
__tablename__ = "song"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
lyric_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID)",
)
suno_task_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Suno API 작업 고유 식별자",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
song_prompt: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="노래 생성에 사용된 프롬프트",
)
song_result_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="노래 결과 URL",
)
duration: Mapped[Optional[float]] = mapped_column(
nullable=True,
comment="노래 재생 시간 (초)",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="songs",
)
lyric: Mapped["Lyric"] = relationship(
"Lyric",
back_populates="songs",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="song",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Song("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.lyric.models import Lyric
from app.video.models import Video
class Song(Base):
"""
노래 테이블
AI를 통해 생성된 노래 정보를 저장합니다.
가사를 기반으로 생성됩니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (pending, processing, completed, failed )
song_prompt: 노래 생성에 사용된 프롬프트
song_result_url: 생성 결과 URL (선택)
language: 출력 언어
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
lyric: 연결된 Lyric
videos: 노래를 사용한 영상 결과 목록
"""
__tablename__ = "song"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
lyric_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID)",
)
suno_task_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Suno API 작업 고유 식별자",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
song_prompt: Mapped[str] = mapped_column(
Text,
nullable=False,
comment="노래 생성에 사용된 프롬프트",
)
song_result_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="노래 결과 URL",
)
duration: Mapped[Optional[float]] = mapped_column(
nullable=True,
comment="노래 재생 시간 (초)",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="Korean",
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="songs",
)
lyric: Mapped["Lyric"] = relationship(
"Lyric",
back_populates="songs",
)
videos: Mapped[List["Video"]] = relationship(
"Video",
back_populates="song",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Song("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)

View File

@ -1,374 +1,374 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import Request
from pydantic import BaseModel, Field
# =============================================================================
# Pydantic Schemas for Song Generation API
# =============================================================================
class GenerateSongRequest(BaseModel):
"""노래 생성 요청 스키마
Usage:
POST /song/generate/{task_id}
Request body for generating a song via Suno API.
Example Request:
{
"lyrics": "인스타 감성의 스테이 머뭄...",
"genre": "k-pop",
"language": "Korean"
}
"""
model_config = {
"json_schema_extra": {
"example": {
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
"genre": "k-pop",
"language": "Korean",
}
}
}
lyrics: str = Field(..., description="노래에 사용할 가사")
genre: str = Field(
...,
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
)
language: str = Field(
default="Korean",
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateSongResponse(BaseModel):
"""노래 생성 응답 스키마
Usage:
POST /song/generate/{task_id}
Returns the task IDs for tracking song generation.
Note:
실패 조건:
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
- Suno API 호출 실패
Example Response (Success):
{
"success": true,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
"error_message": null
}
Example Response (Failure):
{
"success": false,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": null,
"message": "노래 생성 요청에 실패했습니다.",
"error_message": "Suno API connection error"
}
"""
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마 (Legacy)
Note:
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
Example Request:
{
"task_id": "abc123..."
}
"""
task_id: str = Field(..., description="Suno 작업 ID")
class SongClipData(BaseModel):
"""생성된 노래 클립 정보"""
id: Optional[str] = Field(None, description="클립 ID")
audio_url: Optional[str] = Field(None, description="오디오 URL")
stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL")
image_url: Optional[str] = Field(None, description="이미지 URL")
title: Optional[str] = Field(None, description="곡 제목")
status: Optional[str] = Field(None, description="클립 상태")
duration: Optional[float] = Field(None, description="노래 길이 (초)")
class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마
Usage:
GET /song/status/{suno_task_id}
Suno API 작업 상태를 조회합니다.
Note:
상태 :
- PENDING: 대기
- processing: 생성
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
- failed: 생성 실패
- error: API 조회 오류
SUCCESS 상태 :
- 백그라운드에서 MP3 파일 다운로드 시작
- Song 테이블의 status를 completed로 업데이트
- song_result_url에 로컬 파일 경로 저장
Example Response (Processing):
{
"success": true,
"status": "processing",
"message": "노래를 생성하고 있습니다.",
"clips": null,
"raw_response": {...},
"error_message": null
}
Example Response (Success):
{
"success": true,
"status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.",
"clips": [
{
"id": "clip-id",
"audio_url": "https://...",
"stream_audio_url": "https://...",
"image_url": "https://...",
"title": "Song Title",
"status": "complete",
"duration": 60.0
}
],
"raw_response": {...},
"error_message": null
}
Example Response (Failure):
{
"success": false,
"status": "error",
"message": "상태 조회에 실패했습니다.",
"clips": null,
"raw_response": null,
"error_message": "ConnectionError: ..."
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
)
message: str = Field(..., description="상태 메시지")
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class SongListItem(BaseModel):
"""노래 목록 아이템 스키마
Usage:
GET /songs 응답의 개별 노래 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마
Usage:
GET /song/download/{task_id}
Polls for song completion and returns project info with song URL.
Note:
상태 :
- processing: 노래 생성 진행 (song_result_url은 null)
- completed: 노래 생성 완료 (song_result_url 포함)
- failed: 노래 생성 실패
- not_found: task_id에 해당하는 Song 없음
- error: 조회 오류 발생
Example Response (Processing):
{
"success": true,
"status": "processing",
"message": "노래 생성이 진행 중입니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": null
}
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "노래 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
Example Response (Not Found):
{
"success": false,
"status": "not_found",
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": null,
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": "Song not found"
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
# =============================================================================
# Dataclass Schemas (Legacy)
# =============================================================================
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-4o"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-4o"),
prompts=form_data.get("prompts", ""),
)
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import Request
from pydantic import BaseModel, Field
# =============================================================================
# Pydantic Schemas for Song Generation API
# =============================================================================
class GenerateSongRequest(BaseModel):
"""노래 생성 요청 스키마
Usage:
POST /song/generate/{task_id}
Request body for generating a song via Suno API.
Example Request:
{
"lyrics": "인스타 감성의 스테이 머뭄...",
"genre": "k-pop",
"language": "Korean"
}
"""
model_config = {
"json_schema_extra": {
"example": {
"lyrics": "인스타 감성의 스테이 머뭄, 머물러봐요 \n군산 신흥동 말랭이 마을의 마음 힐링 \n사진같은 하루, 여행의 시작 \n보석 같은 이곳은 감성 숙소의 느낌 \n\n인근 명소와 아름다움이 가득한 거리 \n힐링의 바람과 여행의 추억 \n글로벌 감성의 스테이 머뭄, 인스타 감성 \n사진으로 남기고 싶은 그 순간들이 되어줘요",
"genre": "k-pop",
"language": "Korean",
}
}
}
lyrics: str = Field(..., description="노래에 사용할 가사")
genre: str = Field(
...,
description="음악 장르 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz 등)",
)
language: str = Field(
default="Korean",
description="노래 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateSongResponse(BaseModel):
"""노래 생성 응답 스키마
Usage:
POST /song/generate/{task_id}
Returns the task IDs for tracking song generation.
Note:
실패 조건:
- task_id에 해당하는 Project가 없는 경우 (404 HTTPException)
- task_id에 해당하는 Lyric이 없는 경우 (404 HTTPException)
- Suno API 호출 실패
Example Response (Success):
{
"success": true,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": "abc123...",
"message": "노래 생성 요청이 접수되었습니다. suno_task_id로 상태를 조회하세요.",
"error_message": null
}
Example Response (Failure):
{
"success": false,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"suno_task_id": null,
"message": "노래 생성 요청에 실패했습니다.",
"error_message": "Suno API connection error"
}
"""
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
suno_task_id: Optional[str] = Field(None, description="Suno API 작업 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마 (Legacy)
Note:
현재 사용되지 않음. GET /song/status/{suno_task_id} 엔드포인트 사용.
Example Request:
{
"task_id": "abc123..."
}
"""
task_id: str = Field(..., description="Suno 작업 ID")
class SongClipData(BaseModel):
"""생성된 노래 클립 정보"""
id: Optional[str] = Field(None, description="클립 ID")
audio_url: Optional[str] = Field(None, description="오디오 URL")
stream_audio_url: Optional[str] = Field(None, description="스트리밍 오디오 URL")
image_url: Optional[str] = Field(None, description="이미지 URL")
title: Optional[str] = Field(None, description="곡 제목")
status: Optional[str] = Field(None, description="클립 상태")
duration: Optional[float] = Field(None, description="노래 길이 (초)")
class PollingSongResponse(BaseModel):
"""노래 생성 상태 조회 응답 스키마
Usage:
GET /song/status/{suno_task_id}
Suno API 작업 상태를 조회합니다.
Note:
상태 :
- PENDING: 대기
- processing: 생성
- SUCCESS / TEXT_SUCCESS / complete: 생성 완료
- failed: 생성 실패
- error: API 조회 오류
SUCCESS 상태 :
- 백그라운드에서 MP3 파일 다운로드 시작
- Song 테이블의 status를 completed로 업데이트
- song_result_url에 로컬 파일 경로 저장
Example Response (Processing):
{
"success": true,
"status": "processing",
"message": "노래를 생성하고 있습니다.",
"clips": null,
"raw_response": {...},
"error_message": null
}
Example Response (Success):
{
"success": true,
"status": "SUCCESS",
"message": "노래 생성이 완료되었습니다.",
"clips": [
{
"id": "clip-id",
"audio_url": "https://...",
"stream_audio_url": "https://...",
"image_url": "https://...",
"title": "Song Title",
"status": "complete",
"duration": 60.0
}
],
"raw_response": {...},
"error_message": null
}
Example Response (Failure):
{
"success": false,
"status": "error",
"message": "상태 조회에 실패했습니다.",
"clips": null,
"raw_response": null,
"error_message": "ConnectionError: ..."
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (PENDING, processing, SUCCESS, failed)"
)
message: str = Field(..., description="상태 메시지")
clips: Optional[List[SongClipData]] = Field(None, description="생성된 노래 클립 목록")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Suno API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class SongListItem(BaseModel):
"""노래 목록 아이템 스키마
Usage:
GET /songs 응답의 개별 노래 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
class DownloadSongResponse(BaseModel):
"""노래 다운로드 응답 스키마
Usage:
GET /song/download/{task_id}
Polls for song completion and returns project info with song URL.
Note:
상태 :
- processing: 노래 생성 진행 (song_result_url은 null)
- completed: 노래 생성 완료 (song_result_url 포함)
- failed: 노래 생성 실패
- not_found: task_id에 해당하는 Song 없음
- error: 조회 오류 발생
Example Response (Processing):
{
"success": true,
"status": "processing",
"message": "노래 생성이 진행 중입니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": null
}
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "노래 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"language": "Korean",
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
Example Response (Not Found):
{
"success": false,
"status": "not_found",
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
"store_name": null,
"region": null,
"detail_region_info": null,
"task_id": null,
"language": null,
"song_result_url": null,
"created_at": null,
"error_message": "Song not found"
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
language: Optional[str] = Field(None, description="언어")
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
# =============================================================================
# Dataclass Schemas (Legacy)
# =============================================================================
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -1,24 +1,24 @@
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel
class BaseService:
def __init__(self, model, session: AsyncSession):
self.model = model
self.session = session
async def _get(self, id: UUID):
return await self.session.get(self.model, id)
async def _add(self, entity):
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def _update(self, entity):
return await self._add(entity)
async def _delete(self, entity):
await self.session.delete(entity)

File diff suppressed because it is too large Load Diff

View File

@ -1,333 +1,419 @@
"""
Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다.
"""
from datetime import date
from pathlib import Path
import aiofiles
import httpx
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.song.models import Song
from app.utils.common import generate_task_id
from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
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: 저장할 파일명에 사용할 업체명
"""
print(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
print(f"[download_and_save_song] Directory created - path: {file_path}")
# 오디오 파일 다운로드
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(response.content)
print(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"http://{prj_settings.PROJECT_DOMAIN}"
file_url = f"{base_url}{relative_path}"
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "completed"
song.song_result_url = file_url
await session.commit()
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}")
except Exception as e:
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "failed"
await session.commit()
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
async def download_and_upload_song_to_blob(
task_id: str,
audio_url: str,
store_name: str,
) -> None:
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
"""
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
# 파일명에 사용할 수 없는 문자 제거
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
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {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
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "completed"
song.song_result_url = blob_url
await session.commit()
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}")
except Exception as e:
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc())
.limit(1)
)
song = result.scalar_one_or_none()
if song:
song.status = "failed"
await session.commit()
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
if temp_dir.exists():
try:
temp_dir.rmdir()
except Exception:
pass # 디렉토리가 비어있지 않으면 무시
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: 노래 재생 시간 ()
"""
print(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:
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return
task_id = song.task_id
print(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
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
async with httpx.AsyncClient() as client:
response = await client.get(audio_url, timeout=60.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
print(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
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
# Song 테이블 업데이트 (새 세션 사용)
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 song:
song.status = "completed"
song.song_result_url = blob_url
if duration is not None:
song.duration = duration
await session.commit()
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}")
else:
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}")
except Exception as e:
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
# 실패 시 Song 테이블 업데이트
if 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 song:
song.status = "failed"
await session.commit()
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
print(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 # 디렉토리가 비어있지 않으면 무시
"""
Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
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.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
# 로거 설정
logger = logging.getLogger(__name__)
# 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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(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")
print(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}")
print(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}")
print(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}")
print(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}")
print(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"http://{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}")
print(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}")
print(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}")
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
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}")
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
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}")
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
async def download_and_upload_song_to_blob(
task_id: str,
audio_url: str,
store_name: str,
) -> None:
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
# 파일명에 사용할 수 없는 문자 제거
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_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {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_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {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_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
if temp_dir.exists():
try:
temp_dir.rmdir()
except Exception:
pass # 디렉토리가 비어있지 않으면 무시
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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(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}")
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
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}")
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
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}")
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
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}")
print(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}")
print(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 # 디렉토리가 비어있지 않으면 무시

View File

@ -1,329 +1,404 @@
import json
import re
from openai import AsyncOpenAI
from config import apikey_settings
# fmt: off
LYRICS_PROMPT_TEMPLATE_ORI = """
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes:
**Core Analysis:**
- Target customer segments & personas
- Unique Selling Propositions (USPs) and competitive differentiators
- Comprehensive competitor landscape analysis (direct & indirect competitors)
- Market positioning assessment
**Content Strategy Framework:**
- Seasonal content calendar with trend integration
- Visual storytelling direction (shot-by-shot creative guidance)
- Brand tone & voice guidelines
- Content themes aligned with target audience behaviors
**SEO & AEO Optimization:**
- Recommended primary and long-tail keywords
- SEO-optimized taglines and meta descriptions
- Answer Engine Optimization (AEO) content suggestions
- Local search optimization strategies
**Actionable Recommendations:**
- Content distribution strategy across platforms
- KPI measurement framework
- Budget allocation recommendations by content type
콘텐츠 기획(Lyrics, Prompt for SUNO)
1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content.
2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루]
Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean
""".strip()
# fmt: on
LYRICS_PROMPT_TEMPLATE = """
[ROLE]
Content marketing expert and creative songwriter specializing in pension/accommodation services
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
- Output Language: {language}
[INTERNAL ANALYSIS - DO NOT OUTPUT]
Analyze the following internally to inform lyrics creation:
- Target customer segments and personas
- Unique Selling Propositions (USPs)
- Regional characteristics and nearby attractions (within 10 min access)
- Seasonal appeal points
- Emotional triggers for the target audience
[LYRICS REQUIREMENTS]
1. Must Include Elements:
- Business name (TRANSLATED or TRANSLITERATED to {language})
- Region name (TRANSLATED or TRANSLITERATED to {language})
- Main target audience appeal
- Nearby famous places or regional characteristics
2. Keywords to Incorporate (use language-appropriate trendy expressions):
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
- Thai: กสวย, ลใจ, เทยว, ายร, วสวย
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
3. Structure:
- Length: For 1-minute video (approximately 8-12 lines)
- Flow: Verse structure suitable for music
- Rhythm: Natural speech rhythm in the specified language
4. Tone:
- Emotional and heartfelt
- Trendy and viral-friendly
- Relatable to target audience
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
- ALL lyrics content: {language} ONLY
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
- ZERO Korean characters (한글) allowed when output language is NOT Korean
- ZERO mixing of languages - the entire output must be monolingual in {language}
- This is a NON-NEGOTIABLE requirement
- Any output containing characters from other languages is considered a COMPLETE FAILURE
- Violation of this rule invalidates the entire response
[OUTPUT RULES - STRICTLY ENFORCED]
- Output lyrics ONLY
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
- ALL names and places MUST be in {language} script/alphabet
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
- NO titles, descriptions, analysis, or explanations
- NO greetings or closing remarks
- NO additional commentary before or after lyrics
- NO line numbers or labels
- Follow the exact format below
[OUTPUT FORMAT - SUCCESS]
---
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
---
[OUTPUT FORMAT - FAILURE]
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
---
ERROR: [Brief reason for failure in English]
---
""".strip()
# fmt: on
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
[ROLE]
Content marketing expert specializing in pension/accommodation services in Korea
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
[ANALYSIS REQUIREMENTS]
Provide comprehensive marketing analysis including:
1. Target Customer Segments
- Primary and secondary target personas
- Age groups, travel preferences, booking patterns
2. Unique Selling Propositions (USPs)
- Key differentiators based on location and region details
- Competitive advantages
3. Regional Characteristics
- Nearby attractions and famous places (within 10 min access)
- Local food, activities, and experiences
- Transportation accessibility
4. Seasonal Appeal Points
- Best seasons to visit
- Seasonal activities and events
- Peak/off-peak marketing opportunities
5. Marketing Keywords
- Recommended hashtags and search keywords
- Trending terms relevant to the property
[ADDITIONAL REQUIREMENTS]
1. Recommended Tags
- Generate 5 recommended hashtags/tags based on the business characteristics
- Tags should be trendy, searchable, and relevant to accommodation marketing
- Return as JSON with key "tags"
- **MUST be written in Korean (한국어)**
2. Facilities
- Based on the business name and region details, identify 5 likely facilities/amenities
- Consider typical facilities for accommodations in the given region
- Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc.
- Return as JSON with key "facilities"
- **MUST be written in Korean (한국어)**
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
- Analysis sections: Korean only
- Tags: Korean only
- Facilities: Korean only
- This is a NON-NEGOTIABLE requirement
- Any output in English or other languages is considered a FAILURE
- Violation of this rule invalidates the entire response
[OUTPUT RULES - STRICTLY ENFORCED]
- Output analysis ONLY
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
- NO greetings or closing remarks
- NO additional commentary before or after analysis
- Follow the exact format below
[OUTPUT FORMAT - SUCCESS]
---
## 타겟 고객 분석
[한국어로 작성된 타겟 고객 분석]
## 핵심 차별점 (USP)
[한국어로 작성된 USP 분석]
## 지역 특성
[한국어로 작성된 지역 특성 분석]
## 시즌별 매력 포인트
[한국어로 작성된 시즌별 분석]
## 마케팅 키워드
[한국어로 작성된 마케팅 키워드]
## JSON Data
```json
{{
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"],
"facilities": ["부대시설1", "부대시설2", "부대시설3", "부대시설4", "부대시설5"]
}}
```
---
[OUTPUT FORMAT - FAILURE]
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
---
ERROR: [Brief reason for failure in English]
---
""".strip()
# fmt: on
class ChatgptService:
def __init__(
self,
customer_name: str,
region: str,
detail_region_info: str = "",
language: str = "Korean",
):
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo
self.model = "gpt-4o"
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
self.customer_name = customer_name
self.region = region
self.detail_region_info = detail_region_info
self.language = language
def build_lyrics_prompt(self) -> str:
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
return LYRICS_PROMPT_TEMPLATE.format(
customer_name=self.customer_name,
region=self.region,
detail_region_info=self.detail_region_info,
language=self.language,
)
def build_market_analysis_prompt(self) -> str:
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
customer_name=self.customer_name,
region=self.region,
detail_region_info=self.detail_region_info,
)
async def generate(self, prompt: str | None = None) -> str:
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
if prompt is None:
prompt = self.build_lyrics_prompt()
print("Generated Prompt: ", prompt)
completion = await self.client.chat.completions.create(
model=self.model, messages=[{"role": "user", "content": prompt}]
)
message = completion.choices[0].message.content
return message or ""
async def summarize_marketing(self, text: str) -> str:
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리"""
prompt = f"""[ROLE]
마케팅 콘텐츠 요약 전문가
[INPUT]
{text}
[TASK]
텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요.
[OUTPUT REQUIREMENTS]
- 항목별로 구분하여 정리 (: 타겟 고객, 차별점, 지역 특성 )
- 500 이내로 요약
- 핵심 정보만 간결하게 포함
- 한국어로 작성
[OUTPUT FORMAT]
---
[항목별로 구분된 500 이내 요약]
---
"""
completion = await self.client.chat.completions.create(
model=self.model, messages=[{"role": "user", "content": prompt}]
)
message = completion.choices[0].message.content
result = message or ""
# --- 구분자 제거
if result.startswith("---"):
result = result[3:].strip()
if result.endswith("---"):
result = result[:-3].strip()
return result
async def parse_marketing_analysis(self, raw_response: str) -> dict:
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
Returns:
dict: {"report": str, "tags": list[str], "facilities": list[str]}
"""
tags: list[str] = []
facilities: list[str] = []
report = raw_response
# JSON 블록 추출 시도
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
if json_match:
try:
json_data = json.loads(json_match.group(1))
tags = json_data.get("tags", [])
facilities = json_data.get("facilities", [])
# JSON 블록을 제외한 리포트 부분 추출
report = raw_response[: json_match.start()].strip()
# --- 구분자 제거
if report.startswith("---"):
report = report[3:].strip()
if report.endswith("---"):
report = report[:-3].strip()
except json.JSONDecodeError:
pass
# 리포트 내용을 500자로 요약
if report:
report = await self.summarize_marketing(report)
return {"report": report, "tags": tags, "facilities": facilities}
import json
import logging
import re
from openai import AsyncOpenAI
from config import apikey_settings
# 로거 설정
logger = logging.getLogger(__name__)
# fmt: off
LYRICS_PROMPT_TEMPLATE_ORI = """
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes:
**Core Analysis:**
- Target customer segments & personas
- Unique Selling Propositions (USPs) and competitive differentiators
- Comprehensive competitor landscape analysis (direct & indirect competitors)
- Market positioning assessment
**Content Strategy Framework:**
- Seasonal content calendar with trend integration
- Visual storytelling direction (shot-by-shot creative guidance)
- Brand tone & voice guidelines
- Content themes aligned with target audience behaviors
**SEO & AEO Optimization:**
- Recommended primary and long-tail keywords
- SEO-optimized taglines and meta descriptions
- Answer Engine Optimization (AEO) content suggestions
- Local search optimization strategies
**Actionable Recommendations:**
- Content distribution strategy across platforms
- KPI measurement framework
- Budget allocation recommendations by content type
콘텐츠 기획(Lyrics, Prompt for SUNO)
1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content.
2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루]
Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean
""".strip()
# fmt: on
LYRICS_PROMPT_TEMPLATE = """
[ROLE]
Content marketing expert and creative songwriter specializing in pension/accommodation services
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
- Output Language: {language}
[INTERNAL ANALYSIS - DO NOT OUTPUT]
Analyze the following internally to inform lyrics creation:
- Target customer segments and personas
- Unique Selling Propositions (USPs)
- Regional characteristics and nearby attractions (within 10 min access)
- Seasonal appeal points
- Emotional triggers for the target audience
[LYRICS REQUIREMENTS]
1. Must Include Elements:
- Business name (TRANSLATED or TRANSLITERATED to {language})
- Region name (TRANSLATED or TRANSLITERATED to {language})
- Main target audience appeal
- Nearby famous places or regional characteristics
2. Keywords to Incorporate (use language-appropriate trendy expressions):
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
- Thai: กสวย, ลใจ, เทยว, ายร, วสวย
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
3. Structure:
- Length: For 1-minute video (approximately 8-12 lines)
- Flow: Verse structure suitable for music
- Rhythm: Natural speech rhythm in the specified language
4. Tone:
- Emotional and heartfelt
- Trendy and viral-friendly
- Relatable to target audience
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
- ALL lyrics content: {language} ONLY
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
- ZERO Korean characters (한글) allowed when output language is NOT Korean
- ZERO mixing of languages - the entire output must be monolingual in {language}
- This is a NON-NEGOTIABLE requirement
- Any output containing characters from other languages is considered a COMPLETE FAILURE
- Violation of this rule invalidates the entire response
[OUTPUT RULES - STRICTLY ENFORCED]
- Output lyrics ONLY
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
- ALL names and places MUST be in {language} script/alphabet
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
- NO titles, descriptions, analysis, or explanations
- NO greetings or closing remarks
- NO additional commentary before or after lyrics
- NO line numbers or labels
- Follow the exact format below
[OUTPUT FORMAT - SUCCESS]
---
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
---
[OUTPUT FORMAT - FAILURE]
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
---
ERROR: [Brief reason for failure in English]
---
""".strip()
# fmt: on
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
[ROLE]
Content marketing expert specializing in pension/accommodation services in Korea
[INPUT]
- Business Name: {customer_name}
- Region: {region}
- Region Details: {detail_region_info}
[ANALYSIS REQUIREMENTS]
Provide comprehensive marketing analysis including:
1. Target Customer Segments
- Primary and secondary target personas
- Age groups, travel preferences, booking patterns
2. Unique Selling Propositions (USPs)
- Key differentiators based on location and region details
- Competitive advantages
3. Regional Characteristics
- Nearby attractions and famous places (within 10 min access)
- Local food, activities, and experiences
- Transportation accessibility
4. Seasonal Appeal Points
- Best seasons to visit
- Seasonal activities and events
- Peak/off-peak marketing opportunities
5. Marketing Keywords
- Recommended hashtags and search keywords
- Trending terms relevant to the property
[ADDITIONAL REQUIREMENTS]
1. Recommended Tags
- Generate 5 recommended hashtags/tags based on the business characteristics
- Tags should be trendy, searchable, and relevant to accommodation marketing
- Return as JSON with key "tags"
- **MUST be written in Korean (한국어)**
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
- Analysis sections: Korean only
- Tags: Korean only
- This is a NON-NEGOTIABLE requirement
- Any output in English or other languages is considered a FAILURE
- Violation of this rule invalidates the entire response
[OUTPUT RULES - STRICTLY ENFORCED]
- Output analysis ONLY
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
- NO greetings or closing remarks
- NO additional commentary before or after analysis
- Follow the exact format below
[OUTPUT FORMAT - SUCCESS]
---
## 타겟 고객 분석
[한국어로 작성된 타겟 고객 분석]
## 핵심 차별점 (USP)
[한국어로 작성된 USP 분석]
## 지역 특성
[한국어로 작성된 지역 특성 분석]
## 시즌별 매력 포인트
[한국어로 작성된 시즌별 분석]
## 마케팅 키워드
[한국어로 작성된 마케팅 키워드]
## JSON Data
```json
{{
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
}}
```
---
[OUTPUT FORMAT - FAILURE]
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
---
ERROR: [Brief reason for failure in English]
---
""".strip()
# fmt: on
class ChatgptService:
"""ChatGPT API 서비스 클래스
GPT 5.0 모델을 사용하여 마케팅 가사 분석을 생성합니다.
"""
def __init__(
self,
customer_name: str,
region: str,
detail_region_info: str = "",
language: str = "Korean",
):
# 최신 모델: gpt-5-mini
self.model = "gpt-5-mini"
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
self.customer_name = customer_name
self.region = region
self.detail_region_info = detail_region_info
self.language = language
def build_lyrics_prompt(self) -> str:
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
return LYRICS_PROMPT_TEMPLATE.format(
customer_name=self.customer_name,
region=self.region,
detail_region_info=self.detail_region_info,
language=self.language,
)
def build_market_analysis_prompt(self) -> str:
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
customer_name=self.customer_name,
region=self.region,
detail_region_info=self.detail_region_info,
)
async def _call_gpt_api(self, prompt: str) -> str:
"""GPT API를 직접 호출합니다 (내부 메서드).
Args:
prompt: GPT에 전달할 프롬프트
Returns:
GPT 응답 문자열
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
completion = await self.client.chat.completions.create(
model=self.model, messages=[{"role": "user", "content": prompt}]
)
message = completion.choices[0].message.content
return message or ""
async def generate(
self,
prompt: str | None = None,
) -> str:
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
Args:
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
Returns:
GPT 응답 문자열
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
if prompt is None:
prompt = self.build_lyrics_prompt()
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
# GPT API 호출
response = await self._call_gpt_api(prompt)
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
return response
async def summarize_marketing(self, text: str) -> str:
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
Args:
text: 요약할 마케팅 텍스트
Returns:
요약된 텍스트
Raises:
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
prompt = f"""[ROLE]
마케팅 콘텐츠 요약 전문가
[INPUT]
{text}
[TASK]
텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요.
[OUTPUT REQUIREMENTS]
- 5 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
- 항목은 줄바꿈으로 구분
- 500 이내로 요약
- 핵심 정보만 간결하게 포함
- 한국어로 작성
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 제외)
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
[OUTPUT FORMAT - 반드시 아래 형식 준수]
---
타겟 고객
[대상 고객층을 자연스러운 문장으로 설명]
핵심 차별점
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
지역 특성
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
시즌별 포인트
[계절별 매력 포인트를 자연스러운 문장으로 설명]
추천 키워드
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
---
"""
result = await self.generate(prompt=prompt)
# --- 구분자 제거
if result.startswith("---"):
result = result[3:].strip()
if result.endswith("---"):
result = result[:-3].strip()
return result
async def parse_marketing_analysis(
self, raw_response: str, facility_info: str | None = None
) -> dict:
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
Args:
raw_response: ChatGPT 마케팅 분석 응답 원문
facility_info: 크롤링에서 가져온 편의시설 정보 문자열
Returns:
dict: {"report": str, "tags": list[str], "facilities": list[str]}
"""
tags: list[str] = []
facilities: list[str] = []
report = raw_response
# JSON 블록 추출 시도
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
if json_match:
try:
json_data = json.loads(json_match.group(1))
tags = json_data.get("tags", [])
print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}")
# JSON 블록을 제외한 리포트 부분 추출
report = raw_response[: json_match.start()].strip()
# --- 구분자 제거
if report.startswith("---"):
report = report[3:].strip()
if report.endswith("---"):
report = report[:-3].strip()
except json.JSONDecodeError:
print("[parse_marketing_analysis] JSON 파싱 실패")
pass
# 크롤링에서 가져온 facility_info로 facilities 설정
print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}")
if facility_info:
# 쉼표로 구분된 편의시설 문자열을 리스트로 변환
facilities = [f.strip() for f in facility_info.split(",") if f.strip()]
print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}")
else:
facilities = ["등록된 정보 없음"]
print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정")
# 리포트 내용을 500자로 요약
if report:
report = await self.summarize_marketing(report)
print(f"[parse_marketing_analysis] 최종 facilities: {facilities}")
return {"report": report, "tags": tags, "facilities": facilities}

View File

@ -1,24 +1,24 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import cors_settings
# sys.path.append(
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ) # root 경로 추가
class CustomCORSMiddleware:
def __init__(self, app: FastAPI):
self.app = app
def configure_cors(self):
self.app.add_middleware(
CORSMiddleware,
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
allow_methods=cors_settings.CORS_ALLOW_METHODS,
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
max_age=cors_settings.CORS_MAX_AGE,
)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import cors_settings
# sys.path.append(
# os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ) # root 경로 추가
class CustomCORSMiddleware:
def __init__(self, app: FastAPI):
self.app = app
def configure_cors(self):
self.app.add_middleware(
CORSMiddleware,
allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
allow_methods=cors_settings.CORS_ALLOW_METHODS,
allow_headers=cors_settings.CORS_ALLOW_HEADERS,
expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
max_age=cors_settings.CORS_MAX_AGE,
)

View File

@ -1,437 +1,473 @@
"""
Creatomate API 클라이언트 모듈
API 문서: https://creatomate.com/docs/api
## 사용법
```python
from app.utils.creatomate import CreatomateService
# config에서 자동으로 API 키를 가져옴
creatomate = CreatomateService()
# 또는 명시적으로 API 키 전달
creatomate = CreatomateService(api_key="your_api_key")
# 템플릿 목록 조회 (비동기)
templates = await creatomate.get_all_templates_data()
# 특정 템플릿 조회 (비동기)
template = await creatomate.get_one_template_data(template_id)
# 영상 렌더링 요청 (비동기)
response = await creatomate.make_creatomate_call(template_id, modifications)
```
## 성능 최적화
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 API 호출을 줄입니다.
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
- 캐시 만료: 기본 5 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
"""
import copy
import time
from typing import Literal
import httpx
from config import apikey_settings, creatomate_settings
# Orientation 타입 정의
OrientationType = Literal["horizontal", "vertical"]
# =============================================================================
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
_template_cache: dict[str, dict] = {}
# 캐시 TTL (초) - 기본 5분
CACHE_TTL_SECONDS = 300
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_client: httpx.AsyncClient | None = None
async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_client
if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
return _shared_client
async def close_shared_client() -> None:
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
global _shared_client
if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose()
_shared_client = None
print("[CreatomateService] Shared HTTP client closed")
def clear_template_cache() -> None:
"""템플릿 캐시를 전체 삭제합니다."""
global _template_cache
_template_cache.clear()
print("[CreatomateService] Template cache cleared")
def _is_cache_valid(cached_at: float) -> bool:
"""캐시가 유효한지 확인합니다."""
return (time.time() - cached_at) < CACHE_TTL_SECONDS
class CreatomateService:
"""Creatomate API를 통한 영상 생성 서비스
모든 HTTP 호출 메서드는 비동기(async) 구현되어 있습니다.
"""
BASE_URL = "https://api.creatomate.com"
# 템플릿 설정 (config에서 가져옴)
TEMPLATE_CONFIG = {
"horizontal": {
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
},
"vertical": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
},
}
def __init__(
self,
api_key: str | None = None,
orientation: OrientationType = "vertical",
target_duration: float | None = None,
):
"""
Args:
api_key: Creatomate API (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
target_duration: 목표 영상 길이 ()
None일 경우 orientation에 해당하는 기본값 사용
"""
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
self.orientation = orientation
# orientation에 따른 템플릿 설정 가져오기
config = self.TEMPLATE_CONFIG.get(
orientation, self.TEMPLATE_CONFIG["vertical"]
)
self.template_id = config["template_id"]
self.target_duration = (
target_duration if target_duration is not None else config["duration"]
)
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
async def get_all_templates_data(self) -> dict:
"""모든 템플릿 정보를 조회합니다."""
url = f"{self.BASE_URL}/v1/templates"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
return response.json()
async def get_one_template_data(
self,
template_id: str,
use_cache: bool = True,
) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Args:
template_id: 조회할 템플릿 ID
use_cache: 캐시 사용 여부 (기본: True)
Returns:
템플릿 데이터 (deep copy)
"""
global _template_cache
# 캐시 확인
if use_cache and template_id in _template_cache:
cached = _template_cache[template_id]
if _is_cache_valid(cached["cached_at"]):
print(f"[CreatomateService] Cache HIT - {template_id}")
return copy.deepcopy(cached["data"])
else:
# 만료된 캐시 삭제
del _template_cache[template_id]
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
# API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
data = response.json()
# 캐시 저장
_template_cache[template_id] = {
"data": data,
"cached_at": time.time(),
}
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
return copy.deepcopy(data)
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Deprecated: get_one_template_data() 사용하세요.
"""
return await self.get_one_template_data(template_id)
def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
def recursive_parse_component(element: dict) -> dict:
if "name" in element:
result_element_name_type = {element["name"]: element["type"]}
else:
result_element_name_type = {}
if element["type"] == "composition":
minor_component_list = [
recursive_parse_component(minor) for minor in element["elements"]
]
# WARNING: Same name component should shroud other component
for minor_component in minor_component_list:
result_element_name_type.update(minor_component)
return result_element_name_type
result = {}
for result_element_dict in [
recursive_parse_component(component) for component in template_source
]:
result.update(result_element_dict)
return result
async def template_connect_resource_blackbox(
self,
template_id: str,
image_url_list: list[str],
lyric: str,
music_url: str,
) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
Note:
- 이미지는 순차적으로 집어넣기
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야
"""
template_data = await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def elements_connect_resource_blackbox(
self,
elements: list,
image_url_list: list[str],
lyric: str,
music_url: str,
) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def modify_element(self, elements: list, modification: dict) -> list:
"""elements의 source를 modification에 따라 수정합니다."""
def recursive_modify(element: dict) -> None:
if "name" in element:
match element["type"]:
case "image":
element["source"] = modification[element["name"]]
case "audio":
element["source"] = modification.get(element["name"], "")
case "video":
element["source"] = modification[element["name"]]
case "text":
element["source"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
for minor in elements:
recursive_modify(minor)
return elements
async def make_creatomate_call(
self, template_id: str, modifications: dict
) -> dict:
"""Creatomate에 렌더링 요청을 보냅니다.
Note:
response에 요청 정보가 있으니 폴링 필요
"""
url = f"{self.BASE_URL}/v2/renders"
data = {
"template_id": template_id,
"modifications": modifications,
}
client = await get_shared_client()
response = await client.post(
url, json=data, headers=self.headers, timeout=60.0
)
response.raise_for_status()
return response.json()
async def make_creatomate_custom_call(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Note:
response에 요청 정보가 있으니 폴링 필요
"""
url = f"{self.BASE_URL}/v2/renders"
client = await get_shared_client()
response = await client.post(
url, json=source, headers=self.headers, timeout=60.0
)
response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Deprecated: make_creatomate_custom_call() 사용하세요.
"""
return await self.make_creatomate_custom_call(source)
async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Args:
render_id: Creatomate 렌더 ID
Returns:
렌더링 상태 정보
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
"""
url = f"{self.BASE_URL}/v1/renders/{render_id}"
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_render_status_async(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Deprecated: get_render_status() 사용하세요.
"""
return await self.get_render_status(render_id)
def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0
for elem in template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
total_template_duration += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]:
total_template_duration -= animation["duration"]
except Exception as e:
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다."""
template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except Exception as e:
print(
f"[extend_template_duration] Error processing element: {elem}, {e}"
)
return new_template
"""
Creatomate API 클라이언트 모듈
API 문서: https://creatomate.com/docs/api
## 사용법
```python
from app.utils.creatomate import CreatomateService
# config에서 자동으로 API 키를 가져옴
creatomate = CreatomateService()
# 또는 명시적으로 API 키 전달
creatomate = CreatomateService(api_key="your_api_key")
# 템플릿 목록 조회 (비동기)
templates = await creatomate.get_all_templates_data()
# 특정 템플릿 조회 (비동기)
template = await creatomate.get_one_template_data(template_id)
# 영상 렌더링 요청 (비동기)
response = await creatomate.make_creatomate_call(template_id, modifications)
```
## 성능 최적화
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 API 호출을 줄입니다.
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
- 캐시 만료: 기본 5 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
"""
import copy
import logging
import time
from typing import Literal
import httpx
from config import apikey_settings, creatomate_settings
# 로거 설정
logger = logging.getLogger(__name__)
# Orientation 타입 정의
OrientationType = Literal["horizontal", "vertical"]
# =============================================================================
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
_template_cache: dict[str, dict] = {}
# 캐시 TTL (초) - 기본 5분
CACHE_TTL_SECONDS = 300
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_client: httpx.AsyncClient | None = None
async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_client
if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
return _shared_client
async def close_shared_client() -> None:
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
global _shared_client
if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose()
_shared_client = None
print("[CreatomateService] Shared HTTP client closed")
def clear_template_cache() -> None:
"""템플릿 캐시를 전체 삭제합니다."""
global _template_cache
_template_cache.clear()
print("[CreatomateService] Template cache cleared")
def _is_cache_valid(cached_at: float) -> bool:
"""캐시가 유효한지 확인합니다."""
return (time.time() - cached_at) < CACHE_TTL_SECONDS
class CreatomateService:
"""Creatomate API를 통한 영상 생성 서비스
모든 HTTP 호출 메서드는 비동기(async) 구현되어 있습니다.
"""
BASE_URL = "https://api.creatomate.com"
# 템플릿 설정 (config에서 가져옴)
TEMPLATE_CONFIG = {
"horizontal": {
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
},
"vertical": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
},
}
def __init__(
self,
api_key: str | None = None,
orientation: OrientationType = "vertical",
target_duration: float | None = None,
):
"""
Args:
api_key: Creatomate API (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
target_duration: 목표 영상 길이 ()
None일 경우 orientation에 해당하는 기본값 사용
"""
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
self.orientation = orientation
# orientation에 따른 템플릿 설정 가져오기
config = self.TEMPLATE_CONFIG.get(
orientation, self.TEMPLATE_CONFIG["vertical"]
)
self.template_id = config["template_id"]
self.target_duration = (
target_duration if target_duration is not None else config["duration"]
)
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
async def _request(
self,
method: str,
url: str,
timeout: float = 30.0,
**kwargs,
) -> httpx.Response:
"""HTTP 요청을 수행합니다.
Args:
method: HTTP 메서드 ("GET", "POST", etc.)
url: 요청 URL
timeout: 요청 타임아웃 ()
**kwargs: httpx 요청에 전달할 추가 인자
Returns:
httpx.Response: 응답 객체
Raises:
httpx.HTTPError: 요청 실패
"""
logger.info(f"[Creatomate] {method} {url}")
print(f"[Creatomate] {method} {url}")
client = await get_shared_client()
if method.upper() == "GET":
response = await client.get(
url, headers=self.headers, timeout=timeout, **kwargs
)
elif method.upper() == "POST":
response = await client.post(
url, headers=self.headers, timeout=timeout, **kwargs
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
print(f"[Creatomate] Response - Status: {response.status_code}")
return response
async def get_all_templates_data(self) -> dict:
"""모든 템플릿 정보를 조회합니다."""
url = f"{self.BASE_URL}/v1/templates"
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
return response.json()
async def get_one_template_data(
self,
template_id: str,
use_cache: bool = True,
) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Args:
template_id: 조회할 템플릿 ID
use_cache: 캐시 사용 여부 (기본: True)
Returns:
템플릿 데이터 (deep copy)
"""
global _template_cache
# 캐시 확인
if use_cache and template_id in _template_cache:
cached = _template_cache[template_id]
if _is_cache_valid(cached["cached_at"]):
print(f"[CreatomateService] Cache HIT - {template_id}")
return copy.deepcopy(cached["data"])
else:
# 만료된 캐시 삭제
del _template_cache[template_id]
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
# API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}"
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
data = response.json()
# 캐시 저장
_template_cache[template_id] = {
"data": data,
"cached_at": time.time(),
}
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
return copy.deepcopy(data)
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
Deprecated: get_one_template_data() 사용하세요.
"""
return await self.get_one_template_data(template_id)
def parse_template_component_name(self, template_source: list) -> dict:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
def recursive_parse_component(element: dict) -> dict:
if "name" in element:
result_element_name_type = {element["name"]: element["type"]}
else:
result_element_name_type = {}
if element["type"] == "composition":
minor_component_list = [
recursive_parse_component(minor) for minor in element["elements"]
]
# WARNING: Same name component should shroud other component
for minor_component in minor_component_list:
result_element_name_type.update(minor_component)
return result_element_name_type
result = {}
for result_element_dict in [
recursive_parse_component(component) for component in template_source
]:
result.update(result_element_dict)
return result
async def template_connect_resource_blackbox(
self,
template_id: str,
image_url_list: list[str],
lyric: str,
music_url: str,
) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
Note:
- 이미지는 순차적으로 집어넣기
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야
"""
template_data = await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def elements_connect_resource_blackbox(
self,
elements: list,
image_url_list: list[str],
lyric: str,
music_url: str,
) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def modify_element(self, elements: list, modification: dict) -> list:
"""elements의 source를 modification에 따라 수정합니다."""
def recursive_modify(element: dict) -> None:
if "name" in element:
match element["type"]:
case "image":
element["source"] = modification[element["name"]]
case "audio":
element["source"] = modification.get(element["name"], "")
case "video":
element["source"] = modification[element["name"]]
case "text":
element["source"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
for minor in elements:
recursive_modify(minor)
return elements
async def make_creatomate_call(
self, template_id: str, modifications: dict
) -> dict:
"""Creatomate에 렌더링 요청을 보냅니다.
Note:
response에 요청 정보가 있으니 폴링 필요
"""
url = f"{self.BASE_URL}/v2/renders"
data = {
"template_id": template_id,
"modifications": modifications,
}
response = await self._request("POST", url, timeout=60.0, json=data)
response.raise_for_status()
return response.json()
async def make_creatomate_custom_call(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Note:
response에 요청 정보가 있으니 폴링 필요
"""
url = f"{self.BASE_URL}/v2/renders"
response = await self._request("POST", url, timeout=60.0, json=source)
response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
Deprecated: make_creatomate_custom_call() 사용하세요.
"""
return await self.make_creatomate_custom_call(source)
async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Args:
render_id: Creatomate 렌더 ID
Returns:
렌더링 상태 정보
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
"""
url = f"{self.BASE_URL}/v1/renders/{render_id}"
response = await self._request("GET", url, timeout=30.0)
response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_render_status_async(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다.
Deprecated: get_render_status() 사용하세요.
"""
return await self.get_render_status(render_id)
def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다."""
total_template_duration = 0.0
for elem in template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
total_template_duration += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]:
total_template_duration -= animation["duration"]
except Exception as e:
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다."""
template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except Exception as e:
print(
f"[extend_template_duration] Error processing element: {elem}, {e}"
)
return new_template

113
app/utils/nvMapPwScraper.py Normal file
View File

@ -0,0 +1,113 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
class nvMapPwScraper():
# cls vars
is_ready = False
_playwright = None
_browser = None
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 30 # place id timeout threshold seconds
# instance var
page = None
@classmethod
def default_context_builder(cls):
context_builder_dict = {}
context_builder_dict['viewport'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['screen'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
context_builder_dict['locale'] = 'ko-KR'
context_builder_dict['timezone_id']='Asia/Seoul'
return context_builder_dict
@classmethod
async def initiate_scraper(cls):
if not cls._playwright:
cls._playwright = await async_playwright().start()
if not cls._browser:
cls._browser = await cls._playwright.chromium.launch(headless=True)
if not cls._context:
cls._context = await cls._browser.new_context(**cls.default_context_builder())
cls.is_ready = True
def __init__(self):
if not self.is_ready:
raise Exception("nvMapScraper is not initiated")
async def __aenter__(self):
await self.create_page()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.page.close()
async def create_page(self):
self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
})
await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
url = self.page.url.replace("?","?isCorrectAnswer=true&")
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
if (count == self._max_retry / 2):
raise Exception("Failed to identify place id. loading timeout")
else:
raise Exception("Failed to identify place id. item is ambiguous")

View File

@ -1,114 +1,203 @@
import json
import re
import aiohttp
from config import crawler_settings
class GraphQLException(Exception):
pass
class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
base {
id
name
category
roadAddress
address
phone
virtualPhone
microReviews
conveniences
visitorReviewsTotal
}
images { images { origin url } }
cpImages(source: [ugcImage]) { images { origin url } }
}
}"""
DEFAULT_HEADERS: dict = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Referer": "https://map.naver.com/",
"Origin": "https://map.naver.com",
"Content-Type": "application/json",
}
def __init__(self, url: str, cookies: str | None = None):
self.url = url
self.cookies = (
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
)
self.scrap_type: str | None = None
self.rawdata: dict | None = None
self.image_link_list: list[str] | None = None
self.base_info: dict | None = None
def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy()
if self.cookies:
headers["Cookie"] = self.cookies
return headers
def parse_url(self) -> str:
place_pattern = r"/place/(\d+)"
match = re.search(place_pattern, self.url)
if not match:
raise GraphQLException("Failed to parse place ID from URL")
return match[1]
async def scrap(self):
try:
place_id = self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.scrap_type = "GraphQL"
except GraphQLException:
print("fallback")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return
async def _call_get_accommodation(self, place_id: str) -> dict:
payload = {
"operationName": "getAccommodation",
"variables": {"id": place_id, "deviceType": "pc"},
"query": self.OVERVIEW_QUERY,
}
json_payload = json.dumps(payload)
async with aiohttp.ClientSession() as session:
async with session.post(
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
) as response:
if response.status == 200:
return await response.json()
else:
print("실패 상태 코드:", response.status)
raise GraphQLException(
f"Request failed with status {response.status}"
)
# if __name__ == "__main__":
# import asyncio
# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
# scraper = NvMapScraper(url)
# asyncio.run(scraper.scrap())
# print(scraper.image_link_list)
# print(len(scraper.image_link_list) if scraper.image_link_list else 0)
# print(scraper.base_info)
import asyncio
import json
import logging
import re
import aiohttp
import bs4
from config import crawler_settings
# 로거 설정
logger = logging.getLogger(__name__)
class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외"""
pass
class NvMapScraper:
"""네이버 지도 GraphQL API 스크래퍼
네이버 지도에서 숙소/장소 정보를 크롤링합니다.
"""
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초
OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
base {
id
name
category
roadAddress
address
phone
virtualPhone
microReviews
conveniences
visitorReviewsTotal
}
images { images { origin url } }
cpImages(source: [ugcImage]) { images { origin url } }
}
}"""
DEFAULT_HEADERS: dict = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Referer": "https://map.naver.com/",
"Origin": "https://map.naver.com",
"Content-Type": "application/json",
}
def __init__(self, url: str, cookies: str | None = None):
self.url = url
self.cookies = (
cookies if cookies is not None else crawler_settings.NAVER_COOKIES
)
self.scrap_type: str | None = None
self.rawdata: dict | None = None
self.image_link_list: list[str] | None = None
self.base_info: dict | None = None
self.facility_info: str | None = None
def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy()
if self.cookies:
headers["Cookie"] = self.cookies
return headers
async def parse_url(self) -> str:
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
place_pattern = r"/place/(\d+)"
# URL에 place가 없는 경우 단축 URL 처리
if "place" not in self.url:
if "naver.me" in self.url:
async with aiohttp.ClientSession() as session:
async with session.get(self.url) as response:
self.url = str(response.url)
else:
raise GraphQLException("This URL does not contain a place ID")
match = re.search(place_pattern, self.url)
if not match:
raise GraphQLException("Failed to parse place ID from URL")
return match[1]
async def scrap(self):
try:
place_id = await self.parse_url()
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException:
print("fallback")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return
async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
GraphQL 응답 데이터
Raises:
GraphQLException: API 호출 실패
CrawlingTimeoutException: 타임아웃 발생
"""
payload = {
"operationName": "getAccommodation",
"variables": {"id": place_id, "deviceType": "pc"},
"query": self.OVERVIEW_QUERY,
}
json_payload = json.dumps(payload)
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
try:
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
print(f"[NvMapScraper] Requesting place_id: {place_id}")
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
self.GRAPHQL_URL,
data=json_payload,
headers=self._get_request_headers()
) as response:
if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json()
# 실패 상태 코드
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
raise GraphQLException(
f"Request failed with status {response.status}"
)
except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}")
print(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}")
async def _get_facility_string(self, place_id: str) -> str | None:
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
편의시설 정보 문자열 또는 None
"""
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self._get_request_headers()) as response:
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의")
if c_elem:
facilities = c_elem.parent.parent.find("div").string
return facilities
return None
except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
return None
# if __name__ == "__main__":
# import asyncio
# url = "https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84/place/1133638931?c=14.70,0,0,0,dh&placePath=/photo?businessCategory=pension&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191123&fromPanelNum=2&locale=ko&searchText=%EC%8A%A4%ED%85%8C%EC%9D%B4%EB%A8%B8%EB%AD%84&svcName=map_pcv5&timestamp=202512191007&from=map&entry=bmp&filterType=%EC%97%85%EC%B2%B4&businessCategory=pension"
# scraper = NvMapScraper(url)
# asyncio.run(scraper.scrap())
# print(scraper.image_link_list)
# print(len(scraper.image_link_list) if scraper.image_link_list else 0)
# print(scraper.base_info)

View File

@ -1,443 +1,468 @@
"""
Azure Blob Storage 업로드 유틸리티
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
파일 경로 또는 바이트 데이터를 직접 업로드할 있습니다.
URL 경로 형식:
- 음악: {BASE_URL}/{task_id}/song/{파일명}
- 영상: {BASE_URL}/{task_id}/video/{파일명}
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
사용 예시:
from app.utils.upload_blob_as_request import AzureBlobUploader
uploader = AzureBlobUploader(task_id="task-123")
# 파일 경로로 업로드
success = await uploader.upload_music(file_path="my_song.mp3")
success = await uploader.upload_video(file_path="my_video.mp4")
success = await uploader.upload_image(file_path="my_image.png")
# 바이트 데이터로 직접 업로드 (media 저장 없이)
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
success = await uploader.upload_video_bytes(video_bytes, "my_video")
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
print(uploader.public_url) # 마지막 업로드의 공개 URL
성능 최적화:
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 재사용
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
"""
import asyncio
import time
from pathlib import Path
import aiofiles
import httpx
from config import azure_blob_settings
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_blob_client: httpx.AsyncClient | None = None
async def get_shared_blob_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_blob_client
if _shared_blob_client is None or _shared_blob_client.is_closed:
print("[AzureBlobUploader] Creating shared HTTP client...")
_shared_blob_client = httpx.AsyncClient(
timeout=httpx.Timeout(180.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
print("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10")
return _shared_blob_client
async def close_shared_blob_client() -> None:
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
global _shared_blob_client
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
await _shared_blob_client.aclose()
_shared_blob_client = None
print("[AzureBlobUploader] Shared HTTP client closed")
class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
카테고리별 경로:
- 음악: {task_id}/song/{file_name}
- 영상: {task_id}/video/{file_name}
- 이미지: {task_id}/image/{file_name}
Attributes:
task_id: 작업 고유 식별자
"""
# Content-Type 매핑
IMAGE_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
}
def __init__(self, task_id: str):
"""AzureBlobUploader 초기화
Args:
task_id: 작업 고유 식별자
"""
self._task_id = task_id
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
self._last_public_url: str = ""
@property
def task_id(self) -> str:
"""작업 고유 식별자"""
return self._task_id
@property
def public_url(self) -> str:
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
return self._last_public_url
def _build_upload_url(self, category: str, file_name: str) -> str:
"""업로드 URL 생성 (SAS 토큰 포함)"""
# SAS 토큰 앞뒤의 ?, ', " 제거
sas_token = self._sas_token.strip("?'\"")
return (
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
)
def _build_public_url(self, category: str, file_name: str) -> str:
"""공개 URL 생성 (SAS 토큰 제외)"""
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
async def _upload_bytes(
self,
file_content: bytes,
upload_url: str,
headers: dict,
timeout: float,
log_prefix: str,
) -> bool:
"""바이트 데이터를 업로드하는 공통 내부 메서드"""
start_time = time.perf_counter()
try:
print(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client()
client_time = time.perf_counter()
elapsed_ms = (client_time - start_time) * 1000
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
size = len(file_content)
print(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
response = await asyncio.wait_for(
client.put(upload_url, content=file_content, headers=headers),
timeout=timeout,
)
upload_time = time.perf_counter()
duration_ms = (upload_time - start_time) * 1000
if response.status_code in [200, 201]:
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
return True
else:
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}")
return False
except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s "
f"(limit: {timeout}s)")
return False
except httpx.ConnectError as e:
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except httpx.ReadError as e:
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except Exception as e:
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
async def _upload_file(
self,
file_path: str,
category: str,
content_type: str,
timeout: float,
log_prefix: str,
) -> bool:
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
Args:
file_path: 업로드할 파일 경로
category: 카테고리 (song, video, image)
content_type: Content-Type 헤더
timeout: 요청 타임아웃 ()
log_prefix: 로그 접두사
Returns:
bool: 업로드 성공 여부
"""
# 파일 경로에서 파일명 추출
file_name = Path(file_path).name
upload_url = self._build_upload_url(category, file_name)
self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
async with aiofiles.open(file_path, "rb") as file:
file_content = await file.read()
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=timeout,
log_prefix=log_prefix,
)
async def upload_music(self, file_path: str) -> bool:
"""음악 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/song/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_music(file_path="my_song.mp3")
print(uploader.public_url)
"""
return await self._upload_file(
file_path=file_path,
category="song",
content_type="audio/mpeg",
timeout=120.0,
log_prefix="upload_music",
)
async def upload_music_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/song/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
print(uploader.public_url)
"""
# 확장자가 없으면 .mp3 추가
if not Path(file_name).suffix:
file_name = f"{file_name}.mp3"
upload_url = self._build_upload_url("song", file_name)
self._last_public_url = self._build_public_url("song", file_name)
log_prefix = "upload_music_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=120.0,
log_prefix=log_prefix,
)
async def upload_video(self, file_path: str) -> bool:
"""영상 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/video/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_video(file_path="my_video.mp4")
print(uploader.public_url)
"""
return await self._upload_file(
file_path=file_path,
category="video",
content_type="video/mp4",
timeout=180.0,
log_prefix="upload_video",
)
async def upload_video_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/video/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_video_bytes(video_bytes, "my_video")
print(uploader.public_url)
"""
# 확장자가 없으면 .mp4 추가
if not Path(file_name).suffix:
file_name = f"{file_name}.mp4"
upload_url = self._build_upload_url("video", file_name)
self._last_public_url = self._build_public_url("video", file_name)
log_prefix = "upload_video_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=180.0,
log_prefix=log_prefix,
)
async def upload_image(self, file_path: str) -> bool:
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/image/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_image(file_path="my_image.png")
print(uploader.public_url)
"""
extension = Path(file_path).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
return await self._upload_file(
file_path=file_path,
category="image",
content_type=content_type,
timeout=60.0,
log_prefix="upload_image",
)
async def upload_image_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/image/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
with open("my_image.png", "rb") as f:
content = f.read()
success = await uploader.upload_image_bytes(content, "my_image.png")
print(uploader.public_url)
"""
extension = Path(file_name).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name)
log_prefix = "upload_image_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=60.0,
log_prefix=log_prefix,
)
# 사용 예시:
# import asyncio
#
# async def main():
# uploader = AzureBlobUploader(task_id="task-123")
#
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
# await uploader.upload_music("my_song.mp3")
# print(uploader.public_url)
#
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
# await uploader.upload_video("my_video.mp4")
# print(uploader.public_url)
#
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
# await uploader.upload_image("my_image.png")
# print(uploader.public_url)
#
# asyncio.run(main())
"""
Azure Blob Storage 업로드 유틸리티
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
파일 경로 또는 바이트 데이터를 직접 업로드할 있습니다.
URL 경로 형식:
- 음악: {BASE_URL}/{task_id}/song/{파일명}
- 영상: {BASE_URL}/{task_id}/video/{파일명}
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
사용 예시:
from app.utils.upload_blob_as_request import AzureBlobUploader
uploader = AzureBlobUploader(task_id="task-123")
# 파일 경로로 업로드
success = await uploader.upload_music(file_path="my_song.mp3")
success = await uploader.upload_video(file_path="my_video.mp4")
success = await uploader.upload_image(file_path="my_image.png")
# 바이트 데이터로 직접 업로드 (media 저장 없이)
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
success = await uploader.upload_video_bytes(video_bytes, "my_video")
success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
print(uploader.public_url) # 마지막 업로드의 공개 URL
성능 최적화:
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 재사용
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
"""
import asyncio
import logging
import time
from pathlib import Path
import aiofiles
import httpx
from config import azure_blob_settings
# 로거 설정
logger = logging.getLogger(__name__)
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_blob_client: httpx.AsyncClient | None = None
async def get_shared_blob_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_blob_client
if _shared_blob_client is None or _shared_blob_client.is_closed:
print("[AzureBlobUploader] Creating shared HTTP client...")
_shared_blob_client = httpx.AsyncClient(
timeout=httpx.Timeout(180.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
print("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10")
return _shared_blob_client
async def close_shared_blob_client() -> None:
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
global _shared_blob_client
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
await _shared_blob_client.aclose()
_shared_blob_client = None
print("[AzureBlobUploader] Shared HTTP client closed")
class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
카테고리별 경로:
- 음악: {task_id}/song/{file_name}
- 영상: {task_id}/video/{file_name}
- 이미지: {task_id}/image/{file_name}
Attributes:
task_id: 작업 고유 식별자
"""
# Content-Type 매핑
IMAGE_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
}
def __init__(self, task_id: str):
"""AzureBlobUploader 초기화
Args:
task_id: 작업 고유 식별자
"""
self._task_id = task_id
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
self._last_public_url: str = ""
@property
def task_id(self) -> str:
"""작업 고유 식별자"""
return self._task_id
@property
def public_url(self) -> str:
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
return self._last_public_url
def _build_upload_url(self, category: str, file_name: str) -> str:
"""업로드 URL 생성 (SAS 토큰 포함)"""
# SAS 토큰 앞뒤의 ?, ', " 제거
sas_token = self._sas_token.strip("?'\"")
return (
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
)
def _build_public_url(self, category: str, file_name: str) -> str:
"""공개 URL 생성 (SAS 토큰 제외)"""
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
async def _upload_bytes(
self,
file_content: bytes,
upload_url: str,
headers: dict,
timeout: float,
log_prefix: str,
) -> bool:
"""바이트 데이터를 업로드하는 공통 내부 메서드
Args:
file_content: 업로드할 바이트 데이터
upload_url: 업로드 URL
headers: HTTP 헤더
timeout: 요청 타임아웃 ()
log_prefix: 로그 접두사
Returns:
bool: 업로드 성공 여부
"""
size = len(file_content)
start_time = time.perf_counter()
try:
logger.info(f"[{log_prefix}] Starting upload")
print(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client()
client_time = time.perf_counter()
elapsed_ms = (client_time - start_time) * 1000
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
print(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
response = await asyncio.wait_for(
client.put(upload_url, content=file_content, headers=headers),
timeout=timeout,
)
upload_time = time.perf_counter()
duration_ms = (upload_time - start_time) * 1000
if response.status_code in [200, 201]:
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
return True
# 업로드 실패
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}")
return False
except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
return False
except httpx.ConnectError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except httpx.ReadError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except Exception as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
async def _upload_file(
self,
file_path: str,
category: str,
content_type: str,
timeout: float,
log_prefix: str,
) -> bool:
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드
Args:
file_path: 업로드할 파일 경로
category: 카테고리 (song, video, image)
content_type: Content-Type 헤더
timeout: 요청 타임아웃 ()
log_prefix: 로그 접두사
Returns:
bool: 업로드 성공 여부
"""
# 파일 경로에서 파일명 추출
file_name = Path(file_path).name
upload_url = self._build_upload_url(category, file_name)
self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
async with aiofiles.open(file_path, "rb") as file:
file_content = await file.read()
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=timeout,
log_prefix=log_prefix,
)
async def upload_music(self, file_path: str) -> bool:
"""음악 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/song/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_music(file_path="my_song.mp3")
print(uploader.public_url)
"""
return await self._upload_file(
file_path=file_path,
category="song",
content_type="audio/mpeg",
timeout=120.0,
log_prefix="upload_music",
)
async def upload_music_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/song/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
print(uploader.public_url)
"""
# 확장자가 없으면 .mp3 추가
if not Path(file_name).suffix:
file_name = f"{file_name}.mp3"
upload_url = self._build_upload_url("song", file_name)
self._last_public_url = self._build_public_url("song", file_name)
log_prefix = "upload_music_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=120.0,
log_prefix=log_prefix,
)
async def upload_video(self, file_path: str) -> bool:
"""영상 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/video/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_video(file_path="my_video.mp4")
print(uploader.public_url)
"""
return await self._upload_file(
file_path=file_path,
category="video",
content_type="video/mp4",
timeout=180.0,
log_prefix="upload_video",
)
async def upload_video_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/video/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_video_bytes(video_bytes, "my_video")
print(uploader.public_url)
"""
# 확장자가 없으면 .mp4 추가
if not Path(file_name).suffix:
file_name = f"{file_name}.mp4"
upload_url = self._build_upload_url("video", file_name)
self._last_public_url = self._build_public_url("video", file_name)
log_prefix = "upload_video_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=180.0,
log_prefix=log_prefix,
)
async def upload_image(self, file_path: str) -> bool:
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/image/{파일명}
Args:
file_path: 업로드할 파일 경로
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
success = await uploader.upload_image(file_path="my_image.png")
print(uploader.public_url)
"""
extension = Path(file_path).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
return await self._upload_file(
file_path=file_path,
category="image",
content_type=content_type,
timeout=60.0,
log_prefix="upload_image",
)
async def upload_image_bytes(
self, file_content: bytes, file_name: str
) -> bool:
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/image/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명
Returns:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
with open("my_image.png", "rb") as f:
content = f.read()
success = await uploader.upload_image_bytes(content, "my_image.png")
print(uploader.public_url)
"""
extension = Path(file_name).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name)
log_prefix = "upload_image_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
headers=headers,
timeout=60.0,
log_prefix=log_prefix,
)
# 사용 예시:
# import asyncio
#
# async def main():
# uploader = AzureBlobUploader(task_id="task-123")
#
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
# await uploader.upload_music("my_song.mp3")
# print(uploader.public_url)
#
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
# await uploader.upload_video("my_video.mp4")
# print(uploader.public_url)
#
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
# await uploader.upload_image("my_image.png")
# print(uploader.public_url)
#
# asyncio.run(main())

BIN
app/video/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,62 +1,62 @@
from sqladmin import ModelView
from app.video.models import Video
class VideoAdmin(ModelView, model=Video):
name = "영상"
name_plural = "영상 목록"
icon = "fa-solid fa-video"
category = "영상 관리"
page_size = 20
column_list = [
"id",
"project_id",
"lyric_id",
"song_id",
"task_id",
"status",
"created_at",
]
column_details_list = [
"id",
"project_id",
"lyric_id",
"song_id",
"task_id",
"status",
"result_movie_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at"]
column_searchable_list = [
Video.task_id,
Video.status,
]
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Video.id,
Video.project_id,
Video.lyric_id,
Video.song_id,
Video.status,
Video.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"lyric_id": "가사 ID",
"song_id": "노래 ID",
"task_id": "작업 ID",
"status": "상태",
"result_movie_url": "영상 URL",
"created_at": "생성일시",
}
from sqladmin import ModelView
from app.video.models import Video
class VideoAdmin(ModelView, model=Video):
name = "영상"
name_plural = "영상 목록"
icon = "fa-solid fa-video"
category = "영상 관리"
page_size = 20
column_list = [
"id",
"project_id",
"lyric_id",
"song_id",
"task_id",
"status",
"created_at",
]
column_details_list = [
"id",
"project_id",
"lyric_id",
"song_id",
"task_id",
"status",
"result_movie_url",
"created_at",
]
# 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at"]
column_searchable_list = [
Video.task_id,
Video.status,
]
column_default_sort = (Video.created_at, True) # True: DESC (최신순)
column_sortable_list = [
Video.id,
Video.project_id,
Video.lyric_id,
Video.song_id,
Video.status,
Video.created_at,
]
column_labels = {
"id": "ID",
"project_id": "프로젝트 ID",
"lyric_id": "가사 ID",
"song_id": "노래 ID",
"task_id": "작업 ID",
"status": "상태",
"result_movie_url": "영상 URL",
"created_at": "생성일시",
}

View File

@ -1,8 +1,8 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -1,139 +1,139 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.lyric.models import Lyric
from app.song.models import Song
class Video(Base):
"""
영상 결과 테이블
최종 생성된 영상의 결과 URL을 저장합니다.
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
song_id: 연결된 Song의 id (외래키)
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
lyric: 연결된 Lyric
song: 연결된 Song
"""
__tablename__ = "video"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
lyric_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
song_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("song.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Song의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
comment="영상 생성 작업 고유 식별자 (UUID)",
)
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Creatomate API 렌더 ID",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
result_movie_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="생성된 영상 URL",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="videos",
)
lyric: Mapped["Lyric"] = relationship(
"Lyric",
back_populates="videos",
)
song: Mapped["Song"] = relationship(
"Song",
back_populates="videos",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Video("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import Project
from app.lyric.models import Lyric
from app.song.models import Song
class Video(Base):
"""
영상 결과 테이블
최종 생성된 영상의 결과 URL을 저장합니다.
Creatomate 서비스를 통해 이미지와 노래를 결합한 영상 결과입니다.
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
song_id: 연결된 Song의 id (외래키)
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
status: 처리 상태 (pending, processing, completed, failed )
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
created_at: 생성 일시 (자동 설정)
Relationships:
project: 연결된 Project
lyric: 연결된 Lyric
song: 연결된 Song
"""
__tablename__ = "video"
__table_args__ = (
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
lyric_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
song_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("song.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Song의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
comment="영상 생성 작업 고유 식별자 (UUID)",
)
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
comment="Creatomate API 렌더 ID",
)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="처리 상태 (processing, completed, failed)",
)
result_movie_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="생성된 영상 URL",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
# Relationships
project: Mapped["Project"] = relationship(
"Project",
back_populates="videos",
)
lyric: Mapped["Lyric"] = relationship(
"Lyric",
back_populates="videos",
)
song: Mapped["Song"] = relationship(
"Song",
back_populates="videos",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
return "None"
return (value[:max_len] + "...") if len(value) > max_len else value
return (
f"<Video("
f"id={self.id}, "
f"task_id='{truncate(self.task_id)}', "
f"status='{self.status}'"
f")>"
)

View File

@ -1,156 +1,156 @@
"""
Video API Schemas
영상 생성 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Response Schemas
# =============================================================================
class GenerateVideoResponse(BaseModel):
"""영상 생성 응답 스키마
Usage:
GET /video/generate/{task_id}
Returns the task IDs for tracking video generation.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"creatomate_render_id": "render-id-123456",
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
"error_message": None,
}
}
)
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoRenderData(BaseModel):
"""Creatomate 렌더링 결과 데이터"""
id: Optional[str] = Field(None, description="렌더 ID")
status: Optional[str] = Field(None, description="렌더 상태")
url: Optional[str] = Field(None, description="영상 URL")
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
class PollingVideoResponse(BaseModel):
"""영상 생성 상태 조회 응답 스키마
Usage:
GET /video/status/{creatomate_render_id}
Creatomate API 작업 상태를 조회합니다.
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
Example Response (Success):
{
"success": true,
"status": "succeeded",
"message": "영상 생성이 완료되었습니다.",
"render_data": {
"id": "render-id",
"status": "succeeded",
"url": "https://...",
"snapshot_url": "https://..."
},
"raw_response": {...},
"error_message": null
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
)
message: str = Field(..., description="상태 메시지")
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class DownloadVideoResponse(BaseModel):
"""영상 다운로드 응답 스키마
Usage:
GET /video/download/{task_id}
Polls for video completion and returns project info with video URL.
Note:
상태 :
- processing: 영상 생성 진행 (result_movie_url은 null)
- completed: 영상 생성 완료 (result_movie_url 포함)
- failed: 영상 생성 실패
- not_found: task_id에 해당하는 Video 없음
- error: 조회 오류 발생
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "영상 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoListItem(BaseModel):
"""영상 목록 아이템 스키마
Usage:
GET /videos 응답의 개별 영상 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
"""
Video API Schemas
영상 생성 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
# =============================================================================
# Response Schemas
# =============================================================================
class GenerateVideoResponse(BaseModel):
"""영상 생성 응답 스키마
Usage:
GET /video/generate/{task_id}
Returns the task IDs for tracking video generation.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"creatomate_render_id": "render-id-123456",
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
"error_message": None,
}
}
)
success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoRenderData(BaseModel):
"""Creatomate 렌더링 결과 데이터"""
id: Optional[str] = Field(None, description="렌더 ID")
status: Optional[str] = Field(None, description="렌더 상태")
url: Optional[str] = Field(None, description="영상 URL")
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
class PollingVideoResponse(BaseModel):
"""영상 생성 상태 조회 응답 스키마
Usage:
GET /video/status/{creatomate_render_id}
Creatomate API 작업 상태를 조회합니다.
Note:
상태 :
- planned: 예약됨
- waiting: 대기
- transcribing: 트랜스크립션
- rendering: 렌더링
- succeeded: 성공
- failed: 실패
Example Response (Success):
{
"success": true,
"status": "succeeded",
"message": "영상 생성이 완료되었습니다.",
"render_data": {
"id": "render-id",
"status": "succeeded",
"url": "https://...",
"snapshot_url": "https://..."
},
"raw_response": {...},
"error_message": null
}
"""
success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field(
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
)
message: str = Field(..., description="상태 메시지")
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class DownloadVideoResponse(BaseModel):
"""영상 다운로드 응답 스키마
Usage:
GET /video/download/{task_id}
Polls for video completion and returns project info with video URL.
Note:
상태 :
- processing: 영상 생성 진행 (result_movie_url은 null)
- completed: 영상 생성 완료 (result_movie_url 포함)
- failed: 영상 생성 실패
- not_found: task_id에 해당하는 Video 없음
- error: 조회 오류 발생
Example Response (Completed):
{
"success": true,
"status": "completed",
"message": "영상 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00",
"error_message": null
}
"""
success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoListItem(BaseModel):
"""영상 목록 아이템 스키마
Usage:
GET /videos 응답의 개별 영상 정보
Example:
{
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00"
}
"""
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")

File diff suppressed because it is too large Load Diff

View File

@ -1,242 +1,333 @@
"""
Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다.
"""
from pathlib import Path
import aiofiles
import httpx
from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
async def download_and_upload_video_to_blob(
task_id: str,
video_url: str,
store_name: str,
) -> None:
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
).strip()
safe_store_name = safe_store_name or "video"
file_name = f"{safe_store_name}.mp4"
# 임시 저장 경로 생성
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:
raise Exception("Azure Blob Storage 업로드 실패")
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택
result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "completed"
video.result_movie_url = blob_url
await session.commit()
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed")
else:
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
except Exception as e:
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Video 테이블 업데이트
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "failed"
await session.commit()
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
if temp_dir.exists():
try:
temp_dir.rmdir()
except Exception:
pass # 디렉토리가 비어있지 않으면 무시
async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: str,
video_url: str,
store_name: str,
) -> None:
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
Args:
creatomate_render_id: Creatomate API 렌더 ID
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
task_id: str | None = None
try:
# creatomate_render_id로 Video 조회하여 task_id 가져오기
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if not video:
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return
task_id = video.task_id
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_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 "video"
file_name = f"{safe_store_name}.mp4"
# 임시 저장 경로 생성
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:
raise Exception("Azure Blob Storage 업로드 실패")
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
# Video 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "completed"
video.result_movie_url = blob_url
await session.commit()
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed")
else:
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}")
except Exception as e:
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
# 실패 시 Video 테이블 업데이트
if task_id:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if video:
video.status = "failed"
await session.commit()
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
print(f"[download_and_upload_video_by_creatomate_render_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 # 디렉토리가 비어있지 않으면 무시
"""
Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
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.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
# 로거 설정
logger = logging.getLogger(__name__)
# HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
async def _update_video_status(
task_id: str,
status: str,
video_url: str | None = None,
creatomate_render_id: str | None = None,
) -> bool:
"""Video 테이블의 상태를 업데이트합니다.
Args:
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
video_url: 영상 URL
creatomate_render_id: Creatomate render ID (선택)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
if creatomate_render_id:
query_result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
else:
query_result = await session.execute(
select(Video)
.where(Video.task_id == task_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = query_result.scalar_one_or_none()
if video:
video.status = status
if video_url is not None:
video.result_movie_url = video_url
await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
async def _download_video(url: str, task_id: str) -> bytes:
"""URL에서 영상을 다운로드합니다.
Args:
url: 다운로드할 URL
task_id: 로그용 task_id
Returns:
bytes: 다운로드한 파일 내용
Raises:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
print(f"[VideoDownload] 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"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
async def download_and_upload_video_to_blob(
task_id: str,
video_url: str,
store_name: str,
) -> None:
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
).strip()
safe_store_name = safe_store_name or "video"
file_name = f"{safe_store_name}.mp4"
# 임시 저장 경로 생성
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_video_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
content = await _download_video(video_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
upload_success = await uploader.upload_video(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_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed")
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
if temp_dir.exists():
try:
temp_dir.rmdir()
except Exception:
pass # 디렉토리가 비어있지 않으면 무시
async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: str,
video_url: str,
store_name: str,
) -> None:
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
Args:
creatomate_render_id: Creatomate API 렌더 ID
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
task_id: str | None = None
try:
# creatomate_render_id로 Video 조회하여 task_id 가져오기
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(Video)
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc())
.limit(1)
)
video = result.scalar_one_or_none()
if not video:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return
task_id = video.task_id
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_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 "video"
file_name = f"{safe_store_name}.mp4"
# 임시 저장 경로 생성
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_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
content = await _download_video(video_url, task_id)
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(content)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
upload_success = await uploader.upload_video(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_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
# Video 테이블 업데이트
await _update_video_status(
task_id=task_id,
status="completed",
video_url=blob_url,
creatomate_render_id=creatomate_render_id,
)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
finally:
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_by_creatomate_render_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 # 디렉토리가 비어있지 않으면 무시

358
config.py
View File

@ -1,179 +1,179 @@
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent
_base_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env",
env_ignore_empty=True,
extra="ignore",
)
class ProjectSettings(BaseSettings):
PROJECT_NAME: str = Field(default="CastAD")
PROJECT_DOMAIN: str = Field(default="localhost:8000")
VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True)
model_config = _base_config
class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback"
) # Suno 콜백 URL (필수)
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
model_config = _base_config
class CORSSettings(BaseSettings):
# CORS (Cross-Origin Resource Sharing) 설정
# 요청을 허용할 출처(Origin) 목록
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
# 예: ["https://example.com", "https://app.example.com"]
CORS_ALLOW_ORIGINS: list[str] = ["*"]
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
CORS_ALLOW_CREDENTIALS: bool = True
# 허용할 HTTP 메서드 목록
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
CORS_ALLOW_METHODS: list[str] = ["*"]
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
# ["*"]: 모든 헤더 허용
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
CORS_ALLOW_HEADERS: list[str] = ["*"]
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
# Expires, Last-Modified, Pragma)만 접근 가능
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
CORS_EXPOSE_HEADERS: list[str] = []
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
# 0으로 설정 시 매번 preflight 요청 발생
CORS_MAX_AGE: int = 600
model_config = _base_config
class DatabaseSettings(BaseSettings):
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
MYSQL_HOST: str = Field(default="localhost")
MYSQL_PORT: int = Field(default=3306)
MYSQL_USER: str = Field(default="test")
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
MYSQL_DB: str = Field(default="poc")
# Redis 설정
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
model_config = _base_config
@property
def MYSQL_URL(self) -> str:
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
def REDIS_URL(self, db: int = 0) -> str:
"""Redis URL 생성 (db 인수로 기본값 지원)"""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
class SecuritySettings(BaseSettings):
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
model_config = _base_config
class NotificationSettings(BaseSettings):
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
MAIL_PORT: int = 587 # 기본값 추가
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
model_config = _base_config
class CrawlerSettings(BaseSettings):
NAVER_COOKIES: str = Field(default="")
model_config = _base_config
class AzureBlobSettings(BaseSettings):
"""Azure Blob Storage 설정"""
AZURE_BLOB_SAS_TOKEN: str = Field(
default="",
description="Azure Blob Storage SAS 토큰",
)
AZURE_BLOB_BASE_URL: str = Field(
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
description="Azure Blob Storage 기본 URL",
)
model_config = _base_config
class CreatomateSettings(BaseSettings):
"""Creatomate 템플릿 설정"""
# 세로형 템플릿 (기본값)
TEMPLATE_ID_VERTICAL: str = Field(
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
description="Creatomate 세로형 템플릿 ID",
)
TEMPLATE_DURATION_VERTICAL: float = Field(
default=90.0,
description="세로형 템플릿 기본 duration (초)",
)
# 가로형 템플릿
TEMPLATE_ID_HORIZONTAL: str = Field(
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
description="Creatomate 가로형 템플릿 ID",
)
TEMPLATE_DURATION_HORIZONTAL: float = Field(
default=30.0,
description="가로형 템플릿 기본 duration (초)",
)
model_config = _base_config
prj_settings = ProjectSettings()
apikey_settings = APIKeySettings()
db_settings = DatabaseSettings()
security_settings = SecuritySettings()
notification_settings = NotificationSettings()
cors_settings = CORSSettings()
crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings()
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent
_base_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env",
env_ignore_empty=True,
extra="ignore",
)
class ProjectSettings(BaseSettings):
PROJECT_NAME: str = Field(default="CastAD")
PROJECT_DOMAIN: str = Field(default="localhost:8000")
VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True)
model_config = _base_config
class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback"
) # Suno 콜백 URL (필수)
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
model_config = _base_config
class CORSSettings(BaseSettings):
# CORS (Cross-Origin Resource Sharing) 설정
# 요청을 허용할 출처(Origin) 목록
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
# 예: ["https://example.com", "https://app.example.com"]
CORS_ALLOW_ORIGINS: list[str] = ["*"]
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
CORS_ALLOW_CREDENTIALS: bool = True
# 허용할 HTTP 메서드 목록
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
CORS_ALLOW_METHODS: list[str] = ["*"]
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
# ["*"]: 모든 헤더 허용
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
CORS_ALLOW_HEADERS: list[str] = ["*"]
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
# Expires, Last-Modified, Pragma)만 접근 가능
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
CORS_EXPOSE_HEADERS: list[str] = []
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
# 0으로 설정 시 매번 preflight 요청 발생
CORS_MAX_AGE: int = 600
model_config = _base_config
class DatabaseSettings(BaseSettings):
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
MYSQL_HOST: str = Field(default="localhost")
MYSQL_PORT: int = Field(default=3306)
MYSQL_USER: str = Field(default="test")
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
MYSQL_DB: str = Field(default="poc")
# Redis 설정
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
model_config = _base_config
@property
def MYSQL_URL(self) -> str:
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}"
def REDIS_URL(self, db: int = 0) -> str:
"""Redis URL 생성 (db 인수로 기본값 지원)"""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
class SecuritySettings(BaseSettings):
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
model_config = _base_config
class NotificationSettings(BaseSettings):
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
MAIL_PORT: int = 587 # 기본값 추가
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
model_config = _base_config
class CrawlerSettings(BaseSettings):
NAVER_COOKIES: str = Field(default="")
model_config = _base_config
class AzureBlobSettings(BaseSettings):
"""Azure Blob Storage 설정"""
AZURE_BLOB_SAS_TOKEN: str = Field(
default="",
description="Azure Blob Storage SAS 토큰",
)
AZURE_BLOB_BASE_URL: str = Field(
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
description="Azure Blob Storage 기본 URL",
)
model_config = _base_config
class CreatomateSettings(BaseSettings):
"""Creatomate 템플릿 설정"""
# 세로형 템플릿 (기본값)
TEMPLATE_ID_VERTICAL: str = Field(
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
description="Creatomate 세로형 템플릿 ID",
)
TEMPLATE_DURATION_VERTICAL: float = Field(
default=90.0,
description="세로형 템플릿 기본 duration (초)",
)
# 가로형 템플릿
TEMPLATE_ID_HORIZONTAL: str = Field(
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
description="Creatomate 가로형 템플릿 ID",
)
TEMPLATE_DURATION_HORIZONTAL: float = Field(
default=30.0,
description="가로형 템플릿 기본 duration (초)",
)
model_config = _base_config
prj_settings = ProjectSettings()
apikey_settings = APIKeySettings()
db_settings = DatabaseSettings()
security_settings = SecuritySettings()
notification_settings = NotificationSettings()
cors_settings = CORSSettings()
crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings()

BIN
docs/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,297 +1,297 @@
# 비동기 처리 문제 분석 보고서
## 요약
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
---
## 1. 심각도 높음 - 즉시 개선 권장
### 1.1 N+1 쿼리 문제 (video.py:596-612)
```python
# get_videos() 엔드포인트에서
for video in videos:
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
project_result = await session.execute(
select(Project).where(Project.id == video.project_id)
)
project = project_result.scalar_one_or_none()
```
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
**개선 방안**:
```python
# selectinload를 사용한 eager loading
from sqlalchemy.orm import selectinload
query = (
select(Video)
.options(selectinload(Video.project)) # relationship 필요
.where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
# 또는 한 번에 project_ids 수집 후 일괄 조회
project_ids = [v.project_id for v in videos]
projects_result = await session.execute(
select(Project).where(Project.id.in_(project_ids))
)
projects_map = {p.id: p for p in projects_result.scalars().all()}
```
---
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
```python
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
```
**문제점**:
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
- 이 시간 동안 클라이언트 연결이 유지되어야 함
- 다수 동시 요청 시 worker 스레드 고갈 가능성
**개선 방안 (BackgroundTask 패턴)**:
```python
@router.post("/generate")
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
# DB에 processing 상태로 저장
lyric = Lyric(status="processing", ...)
session.add(lyric)
await session.commit()
# 백그라운드에서 ChatGPT 호출
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=prompt,
)
# 즉시 응답 반환
return GenerateLyricResponse(
success=True,
task_id=task_id,
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
)
```
---
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
| 동기 메서드 | 비동기 메서드 |
|------------|--------------|
| `get_all_templates_data()` | 없음 |
| `get_one_template_data()` | `get_one_template_data_async()` |
| `make_creatomate_call()` | 없음 |
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
| `get_render_status()` | `get_render_status_async()` |
**개선 방안**:
```python
# 모든 HTTP 호출 메서드를 async로 통일
async def get_all_templates_data(self) -> dict:
url = f"{self.BASE_URL}/v1/templates"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
return response.json()
# 동기 버전 제거 또는 deprecated 표시
```
---
## 2. 심각도 중간 - 개선 권장
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
```python
@asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
worker_engine = create_async_engine(
url=db_settings.MYSQL_URL,
poolclass=NullPool,
...
)
```
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
**개선 방안**:
```python
# 모듈 레벨에서 워커 전용 엔진 생성
_worker_engine = create_async_engine(
url=db_settings.MYSQL_URL,
poolclass=NullPool,
)
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
@asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
async with _WorkerSessionLocal() as session:
try:
yield session
except Exception as e:
await session.rollback()
raise e
```
---
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
```python
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
```
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
**개선 방안 - 스트리밍 다운로드**:
```python
async with httpx.AsyncClient() as client:
async with client.stream("GET", video_url, timeout=180.0) as response:
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
await f.write(chunk)
```
---
### 2.3 httpx.AsyncClient 반복 생성
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
**개선 방안 - 재사용 가능한 클라이언트**:
```python
# app/utils/http_client.py
from contextlib import asynccontextmanager
import httpx
_client: httpx.AsyncClient | None = None
async def get_http_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(timeout=30.0)
return _client
async def close_http_client():
global _client
if _client:
await _client.aclose()
_client = None
```
---
## 3. 심각도 낮음 - 선택적 개선
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
```python
# 4개의 개별 쿼리가 순차적으로 실행됨
project_result = await session.execute(select(Project).where(...))
lyric_result = await session.execute(select(Lyric).where(...))
song_result = await session.execute(select(Song).where(...))
image_result = await session.execute(select(Image).where(...))
```
**개선 방안 - 병렬 쿼리 실행**:
```python
import asyncio
project_task = session.execute(select(Project).where(Project.task_id == task_id))
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
song_task = session.execute(
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
)
image_task = session.execute(
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
)
project_result, lyric_result, song_result, image_result = await asyncio.gather(
project_task, lyric_task, song_task, image_task
)
```
---
### 3.2 템플릿 조회 캐싱 미적용
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
**개선 방안 - 간단한 메모리 캐싱**:
```python
from functools import lru_cache
from cachetools import TTLCache
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
async def get_one_template_data_async(self, template_id: str) -> dict:
if template_id in _template_cache:
return _template_cache[template_id]
url = f"{self.BASE_URL}/v1/templates/{template_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
data = response.json()
_template_cache[template_id] = data
return data
```
---
## 4. 긍정적인 부분 (잘 구현된 패턴)
| 항목 | 상태 | 설명 |
|------|------|------|
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
---
## 5. 우선순위별 개선 권장 사항
| 우선순위 | 항목 | 예상 효과 |
|----------|------|----------|
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
---
## 분석 일자
2024-12-29
# 비동기 처리 문제 분석 보고서
## 요약
전반적으로 이 프로젝트는 현대적인 비동기 아키텍처를 잘 구현하고 있습니다. 그러나 몇 가지 잠재적인 문제점과 개선 가능한 부분이 발견되었습니다.
---
## 1. 심각도 높음 - 즉시 개선 권장
### 1.1 N+1 쿼리 문제 (video.py:596-612)
```python
# get_videos() 엔드포인트에서
for video in videos:
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
project_result = await session.execute(
select(Project).where(Project.id == video.project_id)
)
project = project_result.scalar_one_or_none()
```
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
**개선 방안**:
```python
# selectinload를 사용한 eager loading
from sqlalchemy.orm import selectinload
query = (
select(Video)
.options(selectinload(Video.project)) # relationship 필요
.where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
# 또는 한 번에 project_ids 수집 후 일괄 조회
project_ids = [v.project_id for v in videos]
projects_result = await session.execute(
select(Project).where(Project.id.in_(project_ids))
)
projects_map = {p.id: p for p in projects_result.scalars().all()}
```
---
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
```python
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
```
**문제점**:
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
- 이 시간 동안 클라이언트 연결이 유지되어야 함
- 다수 동시 요청 시 worker 스레드 고갈 가능성
**개선 방안 (BackgroundTask 패턴)**:
```python
@router.post("/generate")
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
# DB에 processing 상태로 저장
lyric = Lyric(status="processing", ...)
session.add(lyric)
await session.commit()
# 백그라운드에서 ChatGPT 호출
background_tasks.add_task(
generate_lyric_background,
task_id=task_id,
prompt=prompt,
)
# 즉시 응답 반환
return GenerateLyricResponse(
success=True,
task_id=task_id,
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
)
```
---
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
| 동기 메서드 | 비동기 메서드 |
|------------|--------------|
| `get_all_templates_data()` | 없음 |
| `get_one_template_data()` | `get_one_template_data_async()` |
| `make_creatomate_call()` | 없음 |
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
| `get_render_status()` | `get_render_status_async()` |
**개선 방안**:
```python
# 모든 HTTP 호출 메서드를 async로 통일
async def get_all_templates_data(self) -> dict:
url = f"{self.BASE_URL}/v1/templates"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
return response.json()
# 동기 버전 제거 또는 deprecated 표시
```
---
## 2. 심각도 중간 - 개선 권장
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
```python
@asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
# 매 호출마다 새 엔진 생성 - 오버헤드 발생
worker_engine = create_async_engine(
url=db_settings.MYSQL_URL,
poolclass=NullPool,
...
)
```
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
**개선 방안**:
```python
# 모듈 레벨에서 워커 전용 엔진 생성
_worker_engine = create_async_engine(
url=db_settings.MYSQL_URL,
poolclass=NullPool,
)
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
@asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
async with _WorkerSessionLocal() as session:
try:
yield session
except Exception as e:
await session.rollback()
raise e
```
---
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
```python
async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0)
response.raise_for_status()
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content)
```
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
**개선 방안 - 스트리밍 다운로드**:
```python
async with httpx.AsyncClient() as client:
async with client.stream("GET", video_url, timeout=180.0) as response:
response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
await f.write(chunk)
```
---
### 2.3 httpx.AsyncClient 반복 생성
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
**개선 방안 - 재사용 가능한 클라이언트**:
```python
# app/utils/http_client.py
from contextlib import asynccontextmanager
import httpx
_client: httpx.AsyncClient | None = None
async def get_http_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(timeout=30.0)
return _client
async def close_http_client():
global _client
if _client:
await _client.aclose()
_client = None
```
---
## 3. 심각도 낮음 - 선택적 개선
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
```python
# 4개의 개별 쿼리가 순차적으로 실행됨
project_result = await session.execute(select(Project).where(...))
lyric_result = await session.execute(select(Lyric).where(...))
song_result = await session.execute(select(Song).where(...))
image_result = await session.execute(select(Image).where(...))
```
**개선 방안 - 병렬 쿼리 실행**:
```python
import asyncio
project_task = session.execute(select(Project).where(Project.task_id == task_id))
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
song_task = session.execute(
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
)
image_task = session.execute(
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc())
)
project_result, lyric_result, song_result, image_result = await asyncio.gather(
project_task, lyric_task, song_task, image_task
)
```
---
### 3.2 템플릿 조회 캐싱 미적용
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
**개선 방안 - 간단한 메모리 캐싱**:
```python
from functools import lru_cache
from cachetools import TTLCache
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
async def get_one_template_data_async(self, template_id: str) -> dict:
if template_id in _template_cache:
return _template_cache[template_id]
url = f"{self.BASE_URL}/v1/templates/{template_id}"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status()
data = response.json()
_template_cache[template_id] = data
return data
```
---
## 4. 긍정적인 부분 (잘 구현된 패턴)
| 항목 | 상태 | 설명 |
|------|------|------|
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
---
## 5. 우선순위별 개선 권장 사항
| 우선순위 | 항목 | 예상 효과 |
|----------|------|----------|
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
---
## 분석 일자
2024-12-29

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,82 +1,82 @@
-- input_history 테이블
CREATE TABLE input_history (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL,
task_id CHAR(36) NOT NULL,
detail_region_info TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- upload_img_url 테이블
CREATE TABLE upload_img_url (
id INT AUTO_INCREMENT PRIMARY KEY,
task_id CHAR(36) NOT NULL,
img_uid INT NOT NULL,
img_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
);
-- lyrics 테이블
CREATE TABLE lyrics (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
lyrics_prompt TEXT NOT NULL,
lyrics_result LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
);
-- song 테이블
CREATE TABLE song (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
lyrics_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
song_prompt TEXT NOT NULL,
song_result_url_1 VARCHAR(2048),
song_result_url_2 VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
);
-- creatomate_result_url 테이블
CREATE TABLE creatomate_result_url (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
song_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
result_movie_url VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
);
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
-- input_history
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
-- lyrics (input_history_id + task_id 인덱스)
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
-- song (input_history_id + lyrics_id + task_id 인덱스)
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
CREATE INDEX idx_song_task_id ON song(task_id);
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
-- input_history 테이블
CREATE TABLE input_history (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL,
task_id CHAR(36) NOT NULL,
detail_region_info TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- upload_img_url 테이블
CREATE TABLE upload_img_url (
id INT AUTO_INCREMENT PRIMARY KEY,
task_id CHAR(36) NOT NULL,
img_uid INT NOT NULL,
img_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
);
-- lyrics 테이블
CREATE TABLE lyrics (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
lyrics_prompt TEXT NOT NULL,
lyrics_result LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
);
-- song 테이블
CREATE TABLE song (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
lyrics_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
song_prompt TEXT NOT NULL,
song_result_url_1 VARCHAR(2048),
song_result_url_2 VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
);
-- creatomate_result_url 테이블
CREATE TABLE creatomate_result_url (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
song_id INT NOT NULL,
task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL,
result_movie_url VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
);
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
-- input_history
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
-- lyrics (input_history_id + task_id 인덱스)
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
-- song (input_history_id + lyrics_id + task_id 인덱스)
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
CREATE INDEX idx_song_task_id ON song(task_id);
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);

View File

@ -1,83 +1,83 @@
-- input_history 테이블
CREATE TABLE input_history (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
detail_region_info TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- upload_img_url 테이블
CREATE TABLE upload_img_url (
id INT AUTO_INCREMENT PRIMARY KEY,
task_id CHAR(36) NOT NULL, -- input_history와 연결
img_uid INT NOT NULL,
img_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_img_task_image (task_id, img_uid),
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
);
-- lyrics 테이블
CREATE TABLE lyrics (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
lyrics_prompt TEXT NOT NULL,
lyrics_result LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
);
-- song 테이블
CREATE TABLE song (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
lyrics_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
song_prompt TEXT NOT NULL,
song_result_url_1 VARCHAR(2048),
song_result_url_2 VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
);
-- creatomate_result_url 테이블
CREATE TABLE creatomate_result_url (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
song_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
result_movie_url VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
);
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
-- input_history
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
-- lyrics (input_history_id + task_id 인덱스)
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
-- song (input_history_id + lyrics_id + task_id 인덱스)
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
CREATE INDEX idx_song_task_id ON song(task_id);
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
-- input_history 테이블
CREATE TABLE input_history (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE, -- 유니크 UUID
detail_region_info TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- upload_img_url 테이블
CREATE TABLE upload_img_url (
id INT AUTO_INCREMENT PRIMARY KEY,
task_id CHAR(36) NOT NULL, -- input_history와 연결
img_uid INT NOT NULL,
img_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_img_task_image (task_id, img_uid),
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
);
-- lyrics 테이블
CREATE TABLE lyrics (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
lyrics_prompt TEXT NOT NULL,
lyrics_result LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
);
-- song 테이블
CREATE TABLE song (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
lyrics_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
song_prompt TEXT NOT NULL,
song_result_url_1 VARCHAR(2048),
song_result_url_2 VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
);
-- creatomate_result_url 테이블
CREATE TABLE creatomate_result_url (
id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL,
song_id INT NOT NULL,
task_id CHAR(36) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
result_movie_url VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
);
-- ===== 인덱스 추가 (쿼리 성능 최적화) =====
-- input_history
CREATE INDEX idx_input_history_task_id ON input_history(task_id);
-- upload_img_url (task_id 인덱스 + 복합 인덱스)
CREATE INDEX idx_upload_img_url_task_id ON upload_img_url(task_id);
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
-- lyrics (input_history_id + task_id 인덱스)
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
-- song (input_history_id + lyrics_id + task_id 인덱스)
CREATE INDEX idx_song_input_history_id ON song(input_history_id);
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
CREATE INDEX idx_song_task_id ON song(task_id);
-- creatomate_result_url (input_history_id + song_id + task_id 인덱스)
CREATE INDEX idx_creatomate_input_history_id ON creatomate_result_url(input_history_id);
CREATE INDEX idx_creatomate_song_id ON creatomate_result_url(song_id);
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);

View File

@ -1,382 +1,382 @@
# Pydantic ConfigDict 사용 매뉴얼
## 개요
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
## 기본 사용법
```python
from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
strict=True
)
name: str
age: int
```
## 설정 옵션 전체 목록
### 문자열 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
**예시:**
```python
class UserInput(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
str_to_lower=True,
str_min_length=1,
str_max_length=100
)
username: str
user = UserInput(username=" HELLO ")
print(user.username) # "hello"
```
### 유효성 검사
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
**예시 - strict 모드:**
```python
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True)
count: int
# strict=False (기본값): "123" -> 123 자동 변환
# strict=True: "123" 입력 시 ValidationError 발생
```
**예시 - validate_assignment:**
```python
class User(BaseModel):
model_config = ConfigDict(validate_assignment=True)
age: int
user = User(age=25)
user.age = "invalid" # ValidationError 발생
```
### Extra 필드 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
**값 설명:**
- `'ignore'`: 추가 필드 무시 (기본값)
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
- `'forbid'`: 추가 필드 입력 시 에러 발생
**예시:**
```python
class AllowExtra(BaseModel):
model_config = ConfigDict(extra='allow')
name: str
data = AllowExtra(name="John", unknown_field="value")
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
class ForbidExtra(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str
ForbidExtra(name="John", unknown="value") # ValidationError 발생
```
### 불변성 (Immutability)
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
**예시:**
```python
class ImmutableUser(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
age: int
user = ImmutableUser(name="John", age=30)
user.age = 31 # 에러 발생: Instance is frozen
# frozen=True이면 해시 가능
users_set = {user} # 정상 작동
```
### 별칭 (Alias) 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
**예시:**
```python
from pydantic import Field
class APIResponse(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
serialize_by_alias=True
)
user_name: str = Field(alias="userName")
# 둘 다 가능
response1 = APIResponse(userName="John")
response2 = APIResponse(user_name="John")
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
```
**예시 - alias_generator:**
```python
def to_camel(name: str) -> str:
parts = name.split('_')
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
class CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
serialize_by_alias=True
)
first_name: str
last_name: str
data = CamelModel(firstName="John", lastName="Doe")
print(data.model_dump(by_alias=True))
# {"firstName": "John", "lastName": "Doe"}
```
### JSON 스키마
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
**예시 - json_schema_extra:**
```python
class Product(BaseModel):
model_config = ConfigDict(
title="상품 정보",
json_schema_extra={
"example": {
"name": "노트북",
"price": 1500000
},
"description": "상품 데이터를 나타내는 모델"
}
)
name: str
price: int
# OpenAPI/Swagger 문서에 예시가 표시됨
```
**예시 - Callable json_schema_extra:**
```python
def add_examples(schema: dict) -> dict:
schema["examples"] = [
{"name": "예시1", "value": 100},
{"name": "예시2", "value": 200}
]
return schema
class DynamicSchema(BaseModel):
model_config = ConfigDict(json_schema_extra=add_examples)
name: str
value: int
```
### ORM/속성 모드
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
**예시:**
```python
class UserORM:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
age: int
orm_user = UserORM(name="John", age=30)
pydantic_user = UserModel.model_validate(orm_user)
print(pydantic_user) # name='John' age=30
```
### Enum 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
**예시:**
```python
from enum import Enum
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class User(BaseModel):
model_config = ConfigDict(use_enum_values=True)
status: Status
user = User(status=Status.ACTIVE)
print(user.status) # "active" (문자열)
print(type(user.status)) # <class 'str'>
# use_enum_values=False (기본값)이면
# user.status는 Status.ACTIVE (Enum 객체)
```
### 직렬화 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
### 숫자/Float 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
### 기타 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
## 설정 상속
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
```python
class ParentModel(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
extra='allow'
)
name: str
class ChildModel(ParentModel):
model_config = ConfigDict(
frozen=True # 부모 설정 + frozen=True
)
age: int
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
```
## FastAPI와 함께 사용
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
```python
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field
app = FastAPI()
class CreateUserRequest(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
json_schema_extra={
"example": {
"username": "johndoe",
"email": "john@example.com"
}
}
)
username: str = Field(..., min_length=3, max_length=50)
email: str
class UserResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # ORM 객체에서 변환 가능
serialize_by_alias=True
)
id: int
user_name: str = Field(alias="userName")
@app.post("/users", response_model=UserResponse)
async def create_user(user: CreateUserRequest):
# user.username은 자동으로 공백이 제거됨
...
```
## 주의사항
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
2. **populate_by_name은 deprecated**: `validate_by_alias``validate_by_name`을 함께 사용하세요.
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
## 참고 자료
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)
# Pydantic ConfigDict 사용 매뉴얼
## 개요
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
## 기본 사용법
```python
from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
strict=True
)
name: str
age: int
```
## 설정 옵션 전체 목록
### 문자열 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
**예시:**
```python
class UserInput(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
str_to_lower=True,
str_min_length=1,
str_max_length=100
)
username: str
user = UserInput(username=" HELLO ")
print(user.username) # "hello"
```
### 유효성 검사
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
**예시 - strict 모드:**
```python
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True)
count: int
# strict=False (기본값): "123" -> 123 자동 변환
# strict=True: "123" 입력 시 ValidationError 발생
```
**예시 - validate_assignment:**
```python
class User(BaseModel):
model_config = ConfigDict(validate_assignment=True)
age: int
user = User(age=25)
user.age = "invalid" # ValidationError 발생
```
### Extra 필드 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
**값 설명:**
- `'ignore'`: 추가 필드 무시 (기본값)
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
- `'forbid'`: 추가 필드 입력 시 에러 발생
**예시:**
```python
class AllowExtra(BaseModel):
model_config = ConfigDict(extra='allow')
name: str
data = AllowExtra(name="John", unknown_field="value")
print(data.__pydantic_extra__) # {'unknown_field': 'value'}
class ForbidExtra(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str
ForbidExtra(name="John", unknown="value") # ValidationError 발생
```
### 불변성 (Immutability)
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
**예시:**
```python
class ImmutableUser(BaseModel):
model_config = ConfigDict(frozen=True)
name: str
age: int
user = ImmutableUser(name="John", age=30)
user.age = 31 # 에러 발생: Instance is frozen
# frozen=True이면 해시 가능
users_set = {user} # 정상 작동
```
### 별칭 (Alias) 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
**예시:**
```python
from pydantic import Field
class APIResponse(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
serialize_by_alias=True
)
user_name: str = Field(alias="userName")
# 둘 다 가능
response1 = APIResponse(userName="John")
response2 = APIResponse(user_name="John")
print(response1.model_dump(by_alias=True)) # {"userName": "John"}
```
**예시 - alias_generator:**
```python
def to_camel(name: str) -> str:
parts = name.split('_')
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
class CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
serialize_by_alias=True
)
first_name: str
last_name: str
data = CamelModel(firstName="John", lastName="Doe")
print(data.model_dump(by_alias=True))
# {"firstName": "John", "lastName": "Doe"}
```
### JSON 스키마
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
**예시 - json_schema_extra:**
```python
class Product(BaseModel):
model_config = ConfigDict(
title="상품 정보",
json_schema_extra={
"example": {
"name": "노트북",
"price": 1500000
},
"description": "상품 데이터를 나타내는 모델"
}
)
name: str
price: int
# OpenAPI/Swagger 문서에 예시가 표시됨
```
**예시 - Callable json_schema_extra:**
```python
def add_examples(schema: dict) -> dict:
schema["examples"] = [
{"name": "예시1", "value": 100},
{"name": "예시2", "value": 200}
]
return schema
class DynamicSchema(BaseModel):
model_config = ConfigDict(json_schema_extra=add_examples)
name: str
value: int
```
### ORM/속성 모드
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
**예시:**
```python
class UserORM:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
name: str
age: int
orm_user = UserORM(name="John", age=30)
pydantic_user = UserModel.model_validate(orm_user)
print(pydantic_user) # name='John' age=30
```
### Enum 처리
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
**예시:**
```python
from enum import Enum
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class User(BaseModel):
model_config = ConfigDict(use_enum_values=True)
status: Status
user = User(status=Status.ACTIVE)
print(user.status) # "active" (문자열)
print(type(user.status)) # <class 'str'>
# use_enum_values=False (기본값)이면
# user.status는 Status.ACTIVE (Enum 객체)
```
### 직렬화 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
### 숫자/Float 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
### 기타 설정
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
## 설정 상속
자식 모델은 부모 모델의 `model_config`를 상속받습니다.
```python
class ParentModel(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
extra='allow'
)
name: str
class ChildModel(ParentModel):
model_config = ConfigDict(
frozen=True # 부모 설정 + frozen=True
)
age: int
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
```
## FastAPI와 함께 사용
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
```python
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field
app = FastAPI()
class CreateUserRequest(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True,
json_schema_extra={
"example": {
"username": "johndoe",
"email": "john@example.com"
}
}
)
username: str = Field(..., min_length=3, max_length=50)
email: str
class UserResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # ORM 객체에서 변환 가능
serialize_by_alias=True
)
id: int
user_name: str = Field(alias="userName")
@app.post("/users", response_model=UserResponse)
async def create_user(user: CreateUserRequest):
# user.username은 자동으로 공백이 제거됨
...
```
## 주의사항
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
2. **populate_by_name은 deprecated**: `validate_by_alias``validate_by_name`을 함께 사용하세요.
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
## 참고 자료
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,158 +1,158 @@
from app.core.database import Base
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from starlette.authentication import BaseUser
class User(Base, BaseUser):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, nullable=False, autoincrement=True
)
username: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
# age_level 컬럼을 Enum으로 정의
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
age_level: Mapped[str] = mapped_column(
Enum(*age_level_choices, name="age_level_enum"),
nullable=False,
default="10",
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
# # Many-to-many relationship with Group
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
# )
# n:m 관계 (Group) 최적의 lazy 옵션: selectin
group_user: Mapped[list["Group"]] = relationship(
"Group",
secondary="user_group_association",
back_populates="user_group",
lazy="selectin",
)
def __repr__(self) -> str:
return f"id={self.id}, username={self.username}"
@property
def is_authenticated(self) -> bool:
return self.is_active
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.username
# 1:N Relationship - Posts
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(String(10000), nullable=False)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
view_count: Mapped[int] = mapped_column(Integer, default=0)
# Many-to-one relationship with User (using dynamic loading)
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
def __repr__(self) -> str:
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
__table_args__ = (
Index("idx_posts_user_id", "user_id"),
Index("idx_posts_created_at", "created_at"),
Index(
"idx_posts_user_id_created_at", "user_id", "created_at"
), # Composite index
)
# N:M Relationship - Users and Groups
# Association table for many-to-many relationship
# N:M Association Table (중간 테이블)
class UserGroupAssociation(Base):
__tablename__ = "user_group_association"
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
)
# # 관계 정의
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
# # 복합 기본 키 설정
# 기본 키 설정을 위한 __table_args__ 추가
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
def __repr__(self) -> str:
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
# Group 테이블
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String(1000))
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
user_group: Mapped[list["User"]] = relationship(
"User",
secondary="user_group_association",
back_populates="group_user",
lazy="selectin",
)
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
def __repr__(self) -> str:
return f"Group(id={self.id}, name={self.name})"
__table_args__ = (
Index("idx_groups_name", "name"),
Index("idx_groups_is_public", "is_public"),
Index("idx_groups_created_at", "created_at"),
Index("idx_groups_composite", "is_public", "created_at"),
)
from app.core.database import Base
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
PrimaryKeyConstraint,
String,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from starlette.authentication import BaseUser
class User(Base, BaseUser):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
Integer, primary_key=True, nullable=False, autoincrement=True
)
username: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
# age_level 컬럼을 Enum으로 정의
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
age_level: Mapped[str] = mapped_column(
Enum(*age_level_choices, name="age_level_enum"),
nullable=False,
default="10",
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
# # Many-to-many relationship with Group
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
# "UserGroupAssociation", back_populates="user", lazy="dynamic"
# )
# n:m 관계 (Group) 최적의 lazy 옵션: selectin
group_user: Mapped[list["Group"]] = relationship(
"Group",
secondary="user_group_association",
back_populates="user_group",
lazy="selectin",
)
def __repr__(self) -> str:
return f"id={self.id}, username={self.username}"
@property
def is_authenticated(self) -> bool:
return self.is_active
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.username
# 1:N Relationship - Posts
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(String(10000), nullable=False)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
view_count: Mapped[int] = mapped_column(Integer, default=0)
# Many-to-one relationship with User (using dynamic loading)
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
def __repr__(self) -> str:
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
__table_args__ = (
Index("idx_posts_user_id", "user_id"),
Index("idx_posts_created_at", "created_at"),
Index(
"idx_posts_user_id_created_at", "user_id", "created_at"
), # Composite index
)
# N:M Relationship - Users and Groups
# Association table for many-to-many relationship
# N:M Association Table (중간 테이블)
class UserGroupAssociation(Base):
__tablename__ = "user_group_association"
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
)
# # 관계 정의
# user: Mapped["User"] = relationship("User", back_populates="user_groups")
# group: Mapped["Group"] = relationship("Group", back_populates="group_users")
# # 복합 기본 키 설정
# 기본 키 설정을 위한 __table_args__ 추가
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
def __repr__(self) -> str:
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
# Group 테이블
class Group(Base):
__tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String(1000))
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
user_group: Mapped[list["User"]] = relationship(
"User",
secondary="user_group_association",
back_populates="group_user",
lazy="selectin",
)
# Group을 만든 사용자와 관계 (일반적인 1:N 관계)
def __repr__(self) -> str:
return f"Group(id={self.id}, name={self.name})"
__table_args__ = (
Index("idx_groups_name", "name"),
Index("idx_groups_is_public", "is_public"),
Index("idx_groups_created_at", "created_at"),
Index("idx_groups_composite", "is_public", "created_at"),
)

BIN
image/.DS_Store vendored Normal file

Binary file not shown.

BIN
image/2025-12-26/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

108
main.py
View File

@ -1,54 +1,54 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from scalar_fastapi import get_scalar_api_reference
from app.admin_manager import init_admin
from app.core.common import lifespan
from app.database.session import engine
from app.home.api.routers.v1.home import router as home_router
from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.video.api.routers.v1.video import router as video_router
from app.utils.cors import CustomCORSMiddleware
from config import prj_settings
app = FastAPI(
title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION,
description=prj_settings.DESCRIPTION,
lifespan=lifespan,
docs_url=None, # 기본 Swagger UI 비활성화
redoc_url=None, # 기본 ReDoc 비활성화
)
init_admin(app, engine)
custom_cors_middleware = CustomCORSMiddleware(app)
custom_cors_middleware.configure_cors()
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/media", StaticFiles(directory="media"), name="media")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
max_age=-1,
)
@app.get("/docs", include_in_schema=False)
def get_scalar_docs():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title="Scalar API",
)
app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가
app.include_router(video_router) # Video API 라우터 추가
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from scalar_fastapi import get_scalar_api_reference
from app.admin_manager import init_admin
from app.core.common import lifespan
from app.database.session import engine
from app.home.api.routers.v1.home import router as home_router
from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.video.api.routers.v1.video import router as video_router
from app.utils.cors import CustomCORSMiddleware
from config import prj_settings
app = FastAPI(
title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION,
description=prj_settings.DESCRIPTION,
lifespan=lifespan,
docs_url=None, # 기본 Swagger UI 비활성화
redoc_url=None, # 기본 ReDoc 비활성화
)
init_admin(app, engine)
custom_cors_middleware = CustomCORSMiddleware(app)
custom_cors_middleware.configure_cors()
app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/media", StaticFiles(directory="media"), name="media")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
max_age=-1,
)
@app.get("/docs", include_in_schema=False)
def get_scalar_docs():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title="Scalar API",
)
app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가
app.include_router(video_router) # Video API 라우터 추가

BIN
poc/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,29 @@
import asyncio
from nvMapScraper import nvMapScraper
from nvMapPwScraper import nvMapPwScraper
async def main_function():
await nvMapPwScraper.initiate_scraper()
selected = {'title': '<b>스테이</b>,<b>머뭄</b>',
'link': 'https://www.instagram.com/staymeomoom',
'category': '숙박>펜션',
'description': '',
'telephone': '',
'address': '전북특별자치도 군산시 신흥동 63-18',
'roadAddress': '전북특별자치도 군산시 절골길 18',
'mapx': '1267061254',
'mapy': '359864175',
'lng': 126.7061254,
'lat': 35.9864175}
async with nvMapPwScraper() as pw_scraper:
new_url = await pw_scraper.get_place_id_url(selected)
print(new_url)
nv_scraper = nvMapScraper(new_url) # 이후 동일한 플로우
await nv_scraper.scrap()
print(nv_scraper.rawdata)
return
print("running main_funtion..")
asyncio.run(main_function())

View File

@ -0,0 +1,113 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
class nvMapPwScraper():
# cls vars
is_ready = False
_playwright = None
_browser = None
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 30 # place id timeout threshold seconds
# instance var
page = None
@classmethod
def default_context_builder(cls):
context_builder_dict = {}
context_builder_dict['viewport'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['screen'] = {
'width' : cls._win_width,
'height' : cls._win_height
}
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
context_builder_dict['locale'] = 'ko-KR'
context_builder_dict['timezone_id']='Asia/Seoul'
return context_builder_dict
@classmethod
async def initiate_scraper(cls):
if not cls._playwright:
cls._playwright = await async_playwright().start()
if not cls._browser:
cls._browser = await cls._playwright.chromium.launch(headless=True)
if not cls._context:
cls._context = await cls._browser.new_context(**cls.default_context_builder())
cls.is_ready = True
def __init__(self):
if not self.is_ready:
raise Exception("nvMapScraper is not initiated")
async def __aenter__(self):
await self.create_page()
return self
async def __aexit__(self, exc_type, exc, tb):
await self.page.close()
async def create_page(self):
self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
})
await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
page = self.page
await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
url = self.page.url.replace("?","?isCorrectAnswer=true&")
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
if "/place/" in self.page.url:
return self.page.url
if (count == self._max_retry / 2):
raise Exception("Failed to identify place id. loading timeout")
else:
raise Exception("Failed to identify place id. item is ambiguous")

View File

@ -0,0 +1,119 @@
import re
import aiohttp
import json
import asyncio
import bs4
PLACE_PATTERN = r"/place/(\d+)"
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
NAVER_COOKIES="NAC=mQ7mBownbQf4A; NNB=TQPII6AKDBFGQ; PLACE_LANGUAGE=ko; NACT=1; nid_inf=1431570813; NID_AUT=k2T7FraXOdIMRCHzEZIFtHQup+I7b87M5fd7+p65AXZTdGB/gelRmW8s/Q4oDxm8; tooltipDisplayed=true; SRT30=1762660151; NID_SES=AAAB1Lpy3y3hGzuPbJpJl8vvFx18C+HXXuZEFou/YPgocHe7k2/5MpFlgE48X1JF7c7IPoU2khZKkkuLx+tsvWAzOf0TnG/G8RrBGeawnSluSJcKcTdKKRJ4cygKc/OabVxoc3TNZJWxer3vFtXBoXkDS5querVNS6wvcMhA/p4vkPKOeepwKLR+1IJERlQJWZw4q29IdAysrbBNn3Akf9mDA5eTYvMDLYyRkToRh10TVMW/yhyNQeMXlIdnR8U1ZCNqe/9ErYdos5gQDstswEJQQA0T2cHFGJOtmlYMPlnhWado5w521iZXGJyKcA9ZawizM/i5nK5xNYtPGS3cvImUYl6B5ulIipUJSqpj8v2XstK0TZlOGxHToXaVDrCNmSfCA9vFYbTb6xJHB2JRAT3Jik/z6QgLjJLBWRnsucMDqldxoiEDAUHEhY3pjgZ89quR3c3hwAuTlI9hBn5I3e5VQR0Y/GxoS9mIkMF8pJmcGneqnE0BNIt91RN6Se5rDM69B+JWppBXtSir1JGuXADaRLLMP8VlxJX949iH0UYTKWKsrD4OgNNK5aUx24nAH494WPknBMlx4fCMIeWzy7K3sEZkNUn/+A+eHraqIFfbGpveSCNM+8EqEjMgA+YRgg3eig==; _naver_usersession_=Kkgzim/64JicPJzgkIIvqQ==; page_uid=jesTPsqVWUZssE4qJeossssssD0-011300; SRT5=1762662010; BUC=z5Fu3sAYtFwpbRDrrDFYdn4AgK5hNkOqX-DdaLU7VJM="
OVERVIEW_QUERY = '''
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
base {
id
name
category
roadAddress
address
phone
virtualPhone
microReviews
conveniences
visitorReviewsTotal
}
images { images { origin url } }
cpImages(source: [ugcImage]) { images { origin url } }
}
}'''
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Referer": "https://map.naver.com/",
"Origin": "https://map.naver.com",
"Content-Type": "application/json",
"Cookie": NAVER_COOKIES
}
class GraphQLException(Exception):
pass
class nvMapScraper():
url : str = None
scrap_type : str = None
rawdata : dict = None
image_link_list : list[str] = None
base_info : dict = None
def __init__(self, url):
self.url = url
async def parse_url(self):
if 'place' not in self.url:
if 'naver.me' in self.url:
async with aiohttp.ClientSession() as session:
async with session.get(self.url) as response:
self.url = str(response.url)
else:
raise GraphQLException("this shorten url not have place id")
try:
place_id = re.search(PLACE_PATTERN, self.url)[1]
except Exception as E:
raise GraphQLException("Cannot find place id")
return place_id
async def scrap(self):
try:
place_id = await self.parse_url()
data = await self.call_get_accomodation(place_id)
self.rawdata = data
fac_data = await self.get_facility_string(place_id)
self.rawdata['facilities'] = fac_data
self.image_link_list = [nv_image['origin'] for nv_image in data['data']['business']['images']['images']]
self.base_info = data['data']['business']['base']
self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException as G:
print (G)
print("fallback")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return
async def call_get_accomodation(self, place_id):
payload = {
"operationName" : "getAccommodation",
"variables": { "id": place_id, "deviceType": "pc" },
"query": OVERVIEW_QUERY,
}
json_payload = json.dumps(payload)
async with aiohttp.ClientSession() as session:
async with session.post(GRAPHQL_URL, data=json_payload, headers=REQUEST_HEADERS) as response:
response.encoding = 'utf-8'
if response.status == 200: # 요청 성공
return await response.json() # await 주의
else: # 요청 실패
print('실패 상태 코드:', response.status)
print(response.text)
raise Exception()
async def get_facility_string(self, place_id):
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=REQUEST_HEADERS) as response:
soup = bs4.BeautifulSoup(await response.read(), 'html.parser')
c_elem = soup.find('span', 'place_blind', string='편의')
facilities = c_elem.parent.parent.find('div').string
return facilities
# url = "https://naver.me/IgJGCCic"
# scraper = nvMapScraper(url)
# asyncio.run(scraper.scrap())
# print(scraper.image_link_list)
# print(len(scraper.image_link_list))

View File

@ -1,226 +1,226 @@
import copy
import requests
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
# Creatomate 템플릿 정보 전부 가져오기
class Creatomate:
base_url: str = "https://api.creatomate.com"
def __init__(self, api_key):
self.api_key = api_key
def get_all_templates_data(self) -> dict:
url = Creatomate.base_url + "/v1/templates"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.get(url, headers=headers)
return response.json()
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
def get_one_template_data(self, template_id: str) -> dict:
url = Creatomate.base_url + f"/v1/templates/{template_id}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.get(url, headers=headers)
return response.json()
# 템플릿 정보 파싱하여 리소스 이름 추출하기
def parse_template_component_name(self, template_source: dict) -> dict:
def recursive_parse_component(element: dict) -> dict:
if "name" in element:
result_element_name_type = {element["name"]: element["type"]}
else:
result_element_name_type = {}
if element["type"] == "composition":
minor_component_list = [
recursive_parse_component(minor) for minor in element["elements"]
]
for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
result_element_name_type.update(minor_component)
return result_element_name_type
result = {}
for result_element_dict in [
recursive_parse_component(component) for component in template_source
]:
result.update(result_element_dict)
return result
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
# 이미지는 순차적으로 집어넣기
# 가사는 개행마다 한 텍스트 삽입
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
def template_connect_resource_blackbox(
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
) -> dict:
template_data = self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def elements_connect_resource_blackbox(
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
) -> dict:
template_component_data = self.parse_template_component_name(elements)
lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def modify_element(self, elements: list, modification: dict):
def recursive_modify(element: dict) -> dict:
if "name" in element:
match element["type"]:
case "image":
element["source"] = modification[element["name"]]
case "audio":
element["source"] = modification.get(element["name"], "")
case "video":
element["source"] = modification[element["name"]]
case "text":
element["source"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
for minor in elements:
recursive_modify(minor)
return elements
# Creatomate에 생성 요청
# response에 요청 정보 있으니 풀링 필요
def make_creatomate_call(self, template_id: str, modifications: dict):
url = Creatomate.base_url + "/v2/renders"
data = {"template_id": template_id, "modifications": modifications}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.post(url, json=data, headers=headers)
return response
# Creatomate에 생성 요청 without template
# response에 요청 정보 있으니 풀링 필요
def make_creatomate_custom_call(self, source: str):
url = Creatomate.base_url + "/v2/renders"
data = source
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.post(url, json=data, headers=headers)
return response
def calc_scene_duration(self, template: dict):
total_template_duration = 0
for elem in template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
total_template_duration += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]:
total_template_duration -= animation["duration"]
except:
print(elem)
return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float):
template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except:
print(elem)
return new_template
# Azure사용한 legacy 코드 원본
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
# secret_client = get_keyvault_client()
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
# media_folder_path = f"{user_idx}/{task_idx}"
# lyric_path = f"{media_folder_path}/lyric.txt"
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8')
# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path)
# image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
# template_data = get_one_template_data(template_id)
# template_component_data = parse_template_component_name(template_data['source']['elements'])
# lyric.replace("\r", "")
# lyric_splited = lyric.split("\n")
# modifications = {}
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
# match template_type:
# case 'image':
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
# case 'text':
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)]
# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
# print(modifications)
# return modifications
import copy
import requests
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
# Creatomate 템플릿 정보 전부 가져오기
class Creatomate:
base_url: str = "https://api.creatomate.com"
def __init__(self, api_key):
self.api_key = api_key
def get_all_templates_data(self) -> dict:
url = Creatomate.base_url + "/v1/templates"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.get(url, headers=headers)
return response.json()
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
def get_one_template_data(self, template_id: str) -> dict:
url = Creatomate.base_url + f"/v1/templates/{template_id}"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.get(url, headers=headers)
return response.json()
# 템플릿 정보 파싱하여 리소스 이름 추출하기
def parse_template_component_name(self, template_source: dict) -> dict:
def recursive_parse_component(element: dict) -> dict:
if "name" in element:
result_element_name_type = {element["name"]: element["type"]}
else:
result_element_name_type = {}
if element["type"] == "composition":
minor_component_list = [
recursive_parse_component(minor) for minor in element["elements"]
]
for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
result_element_name_type.update(minor_component)
return result_element_name_type
result = {}
for result_element_dict in [
recursive_parse_component(component) for component in template_source
]:
result.update(result_element_dict)
return result
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
# 이미지는 순차적으로 집어넣기
# 가사는 개행마다 한 텍스트 삽입
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
def template_connect_resource_blackbox(
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
) -> dict:
template_data = self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"]
)
lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def elements_connect_resource_blackbox(
self, elements: list, image_url_list: list[str], lyric: str, music_url: str
) -> dict:
template_component_data = self.parse_template_component_name(elements)
lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type:
case "image":
modifications[template_component_name] = image_url_list[
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
modifications["audio-music"] = music_url
return modifications
def modify_element(self, elements: list, modification: dict):
def recursive_modify(element: dict) -> dict:
if "name" in element:
match element["type"]:
case "image":
element["source"] = modification[element["name"]]
case "audio":
element["source"] = modification.get(element["name"], "")
case "video":
element["source"] = modification[element["name"]]
case "text":
element["source"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
for minor in elements:
recursive_modify(minor)
return elements
# Creatomate에 생성 요청
# response에 요청 정보 있으니 풀링 필요
def make_creatomate_call(self, template_id: str, modifications: dict):
url = Creatomate.base_url + "/v2/renders"
data = {"template_id": template_id, "modifications": modifications}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.post(url, json=data, headers=headers)
return response
# Creatomate에 생성 요청 without template
# response에 요청 정보 있으니 풀링 필요
def make_creatomate_custom_call(self, source: str):
url = Creatomate.base_url + "/v2/renders"
data = source
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
response = requests.post(url, json=data, headers=headers)
return response
def calc_scene_duration(self, template: dict):
total_template_duration = 0
for elem in template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
total_template_duration += elem["duration"]
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]:
total_template_duration -= animation["duration"]
except:
print(elem)
return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float):
template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]:
try:
if elem["type"] == "audio":
continue
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem:
continue
for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except:
print(elem)
return new_template
# Azure사용한 legacy 코드 원본
# def template_connect_resource_blackbox(template_id, user_idx, task_idx):
# secret_client = get_keyvault_client()
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
# media_folder_path = f"{user_idx}/{task_idx}"
# lyric_path = f"{media_folder_path}/lyric.txt"
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8')
# media_list = az_storage.az_storage_get_ado2_media_list(media_folder_path)
# image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
# template_data = get_one_template_data(template_id)
# template_component_data = parse_template_component_name(template_data['source']['elements'])
# lyric.replace("\r", "")
# lyric_splited = lyric.split("\n")
# modifications = {}
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
# match template_type:
# case 'image':
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
# case 'text':
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)]
# modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
# print(modifications)
# return modifications

View File

@ -1,55 +1,55 @@
import creatomate
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
target_duration = 90.0 # s
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
template = creato.get_one_template_data(shortform_4_template_id)
uploaded_image_url_list = [
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg",
]
lyric = """
진짜 맛있는 추어탕의 향연
청담추어정 본점이야 말로
가족이 함께 먹는
여수동 맛집으로 명성을 떨쳐
주차 가능, 단체 이용도 OK
입맛을 사로잡는
청담추어정, 진정한
말복을 지나고 느껴보세요
한산한 분위기, 편안한 식사
상황 추어탕으로 더욱 완벽
톡톡 튀는 , 입에 느껴
청담추어정에서 즐겨보세요
성남 출신의 맛집으로
여수대로에서 빛나는 그곳
청담추어정, 진짜 맛의
여러분을 초대합니다 여기에
#청담추어정 #여수동맛집
성남에서 만나는 진짜
"""
song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3"
modifications = creato.elements_connect_resource_blackbox(
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
)
new_elements = creato.modify_element(template["source"]["elements"], modifications)
template["source"]["elements"] = new_elements
last_template = creato.extend_template_duration(template, target_duration)
creato.make_creatomate_custom_call(last_template["source"])
import creatomate
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
shortform_4_template_id = "e8c7b43f-de4b-4ba3-b8eb-5df688569193"
target_duration = 90.0 # s
creato = creatomate.Creatomate(CREATOMATE_API_KEY)
template = creato.get_one_template_data(shortform_4_template_id)
uploaded_image_url_list = [
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_000_385523a5_99f2e8a8.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818306_001_d4cf6ec9_b81a1fdc.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_002_e4a0b276_680c5020.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_003_657f8c26_9f2c7168.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818307_004_9500e39d_24b9dad0.jpg",
"https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/crawling-images/crawler4_img_1755818308_005_c3536641_9d490ccf.jpg",
]
lyric = """
진짜 맛있는 추어탕의 향연
청담추어정 본점이야 말로
가족이 함께 먹는
여수동 맛집으로 명성을 떨쳐
주차 가능, 단체 이용도 OK
입맛을 사로잡는
청담추어정, 진정한
말복을 지나고 느껴보세요
한산한 분위기, 편안한 식사
상황 추어탕으로 더욱 완벽
톡톡 튀는 , 입에 느껴
청담추어정에서 즐겨보세요
성남 출신의 맛집으로
여수대로에서 빛나는 그곳
청담추어정, 진짜 맛의
여러분을 초대합니다 여기에
#청담추어정 #여수동맛집
성남에서 만나는 진짜
"""
song_url = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/dev-user-idx/dev-task-idx/stay.mp3"
modifications = creato.elements_connect_resource_blackbox(
template["source"]["elements"], uploaded_image_url_list, lyric, song_url
)
new_elements = creato.modify_element(template["source"]["elements"], modifications)
template["source"]["elements"] = new_elements
last_template = creato.extend_template_duration(template, target_duration)
creato.make_creatomate_custom_call(last_template["source"])

View File

@ -9,6 +9,7 @@ dependencies = [
"aiohttp>=3.13.2",
"aiomysql>=0.3.2",
"asyncmy>=0.2.10",
"beautifulsoup4>=4.14.3",
"fastapi-cli>=0.0.16",
"fastapi[standard]>=0.125.0",
"openai>=2.13.0",

24
uv.lock
View File

@ -157,6 +157,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@ -751,6 +764,7 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiomysql" },
{ name = "asyncmy" },
{ name = "beautifulsoup4" },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-cli" },
{ name = "openai" },
@ -775,6 +789,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.2" },
{ name = "aiomysql", specifier = ">=0.3.2" },
{ name = "asyncmy", specifier = ">=0.2.10" },
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
{ name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "openai", specifier = ">=2.13.0" },
@ -1241,6 +1256,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
]
[[package]]
name = "sqladmin"
version = "0.22.0"