Merge branch 'main' into scraper-poc

insta
jaehwang 2026-01-13 15:50:58 +09:00
commit ba26284451
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 fastapi import FastAPI
from sqladmin import Admin from sqladmin import Admin
from app.database.session import engine from app.database.session import engine
from app.home.api.home_admin import ImageAdmin, ProjectAdmin from app.home.api.home_admin import ImageAdmin, ProjectAdmin
from app.lyric.api.lyrics_admin import LyricAdmin from app.lyric.api.lyrics_admin import LyricAdmin
from app.song.api.song_admin import SongAdmin from app.song.api.song_admin import SongAdmin
from app.video.api.video_admin import VideoAdmin from app.video.api.video_admin import VideoAdmin
from config import prj_settings from config import prj_settings
# https://github.com/aminalaee/sqladmin # https://github.com/aminalaee/sqladmin
def init_admin( def init_admin(
app: FastAPI, app: FastAPI,
db_engine: engine, db_engine: engine,
base_url: str = prj_settings.ADMIN_BASE_URL, base_url: str = prj_settings.ADMIN_BASE_URL,
) -> Admin: ) -> Admin:
admin = Admin( admin = Admin(
app, app,
db_engine, db_engine,
base_url=base_url, base_url=base_url,
) )
# 프로젝트 관리 # 프로젝트 관리
admin.add_view(ProjectAdmin) admin.add_view(ProjectAdmin)
admin.add_view(ImageAdmin) admin.add_view(ImageAdmin)
# 가사 관리 # 가사 관리
admin.add_view(LyricAdmin) admin.add_view(LyricAdmin)
# 노래 관리 # 노래 관리
admin.add_view(SongAdmin) admin.add_view(SongAdmin)
# 영상 관리 # 영상 관리
admin.add_view(VideoAdmin) admin.add_view(VideoAdmin)
return admin return admin

View File

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

View File

@ -1,114 +1,313 @@
from fastapi import FastAPI, HTTPException, Request, Response, status import logging
from fastapi.responses import JSONResponse import traceback
from functools import wraps
from typing import Any, Callable, TypeVar
class FastShipError(Exception):
"""Base exception for all exceptions in fastship api""" from fastapi import FastAPI, HTTPException, Request, Response, status
# status_code to be returned for this exception from fastapi.responses import JSONResponse
# when it is handled from sqlalchemy.exc import SQLAlchemyError
status = status.HTTP_400_BAD_REQUEST
# 로거 설정
logger = logging.getLogger(__name__)
class EntityNotFound(FastShipError):
"""Entity not found in database""" T = TypeVar("T")
status = status.HTTP_404_NOT_FOUND
class FastShipError(Exception):
"""Base exception for all exceptions in fastship api"""
class BadPassword(FastShipError): # status_code to be returned for this exception
"""Password is not strong enough or invalid""" # when it is handled
status = status.HTTP_400_BAD_REQUEST
status = status.HTTP_400_BAD_REQUEST
class EntityNotFound(FastShipError):
class ClientNotAuthorized(FastShipError): """Entity not found in database"""
"""Client is not authorized to perform the action"""
status = status.HTTP_404_NOT_FOUND
status = status.HTTP_401_UNAUTHORIZED
class BadPassword(FastShipError):
class ClientNotVerified(FastShipError): """Password is not strong enough or invalid"""
"""Client is not verified"""
status = status.HTTP_400_BAD_REQUEST
status = status.HTTP_401_UNAUTHORIZED
class ClientNotAuthorized(FastShipError):
class NothingToUpdate(FastShipError): """Client is not authorized to perform the action"""
"""No data provided to update"""
status = status.HTTP_401_UNAUTHORIZED
class BadCredentials(FastShipError):
"""User email or password is incorrect""" class ClientNotVerified(FastShipError):
"""Client is not verified"""
status = status.HTTP_401_UNAUTHORIZED
status = status.HTTP_401_UNAUTHORIZED
class InvalidToken(FastShipError):
"""Access token is invalid or expired""" class NothingToUpdate(FastShipError):
"""No data provided to update"""
status = status.HTTP_401_UNAUTHORIZED
class BadCredentials(FastShipError):
class DeliveryPartnerNotAvailable(FastShipError): """User email or password is incorrect"""
"""Delivery partner/s do not service the destination"""
status = status.HTTP_401_UNAUTHORIZED
status = status.HTTP_406_NOT_ACCEPTABLE
class InvalidToken(FastShipError):
class DeliveryPartnerCapacityExceeded(FastShipError): """Access token is invalid or expired"""
"""Delivery partner has reached their max handling capacity"""
status = status.HTTP_401_UNAUTHORIZED
status = status.HTTP_406_NOT_ACCEPTABLE
class DeliveryPartnerNotAvailable(FastShipError):
def _get_handler(status: int, detail: str): """Delivery partner/s do not service the destination"""
# Define
def handler(request: Request, exception: Exception) -> Response: status = status.HTTP_406_NOT_ACCEPTABLE
# DEBUG PRINT STATEMENT 👇
from rich import print, panel
print( class DeliveryPartnerCapacityExceeded(FastShipError):
panel.Panel( """Delivery partner has reached their max handling capacity"""
exception.__class__.__name__,
title="Handled Exception", status = status.HTTP_406_NOT_ACCEPTABLE
border_style="red",
),
) # =============================================================================
# DEBUG PRINT STATEMENT 👆 # 데이터베이스 관련 예외
# =============================================================================
# Raise HTTPException with given status and detail
# can return JSONResponse as well
raise HTTPException( class DatabaseError(FastShipError):
status_code=status, """Database operation failed"""
detail=detail,
) status = status.HTTP_503_SERVICE_UNAVAILABLE
# Return ExceptionHandler required with given
# status and detail for HTTPExcetion above
return handler class DatabaseConnectionError(DatabaseError):
"""Database connection failed"""
def add_exception_handlers(app: FastAPI): status = status.HTTP_503_SERVICE_UNAVAILABLE
# Get all subclass of 👇, our custom exceptions
exception_classes = FastShipError.__subclasses__()
class DatabaseTimeoutError(DatabaseError):
for exception_class in exception_classes: """Database operation timed out"""
# Add exception handler
app.add_exception_handler( status = status.HTTP_504_GATEWAY_TIMEOUT
# Custom exception class
exception_class,
# Get handler function # =============================================================================
_get_handler( # 외부 서비스 관련 예외
status=exception_class.status, # =============================================================================
detail=exception_class.__doc__,
),
) class ExternalServiceError(FastShipError):
"""External service call failed"""
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception): status = status.HTTP_502_BAD_GATEWAY
return JSONResponse(
content={"detail": "Something went wrong..."},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, class GPTServiceError(ExternalServiceError):
headers={ """GPT API call failed"""
"X-Error": f"{exception}",
} 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 uuid import UUID
from redis.asyncio import Redis from redis.asyncio import Redis
from app.config import db_settings from app.config import db_settings
_token_blacklist = Redis( _token_blacklist = Redis(
host=db_settings.REDIS_HOST, host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT, port=db_settings.REDIS_PORT,
db=0, db=0,
) )
_shipment_verification_codes = Redis( _shipment_verification_codes = Redis(
host=db_settings.REDIS_HOST, host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT, port=db_settings.REDIS_PORT,
db=1, db=1,
decode_responses=True, decode_responses=True,
) )
async def add_jti_to_blacklist(jti: str): async def add_jti_to_blacklist(jti: str):
await _token_blacklist.set(jti, "blacklisted") await _token_blacklist.set(jti, "blacklisted")
async def is_jti_blacklisted(jti: str) -> bool: async def is_jti_blacklisted(jti: str) -> bool:
return await _token_blacklist.exists(jti) return await _token_blacklist.exists(jti)
async def add_shipment_verification_code(id: UUID, code: int): async def add_shipment_verification_code(id: UUID, code: int):
await _shipment_verification_codes.set(str(id), code) await _shipment_verification_codes.set(str(id), code)
async def get_shipment_verification_code(id: UUID) -> str: async def get_shipment_verification_code(id: UUID) -> str:
return str(await _shipment_verification_codes.get(str(id))) return str(await _shipment_verification_codes.get(str(id)))

View File

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

View File

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

View File

@ -1,15 +1,15 @@
"""API 1 Version Router Module.""" """API 1 Version Router Module."""
# from fastapi import APIRouter, Depends # from fastapi import APIRouter, Depends
# API 버전 1 라우터를 정의합니다. # API 버전 1 라우터를 정의합니다.
# router = APIRouter( # router = APIRouter(
# prefix="/api/v1", # prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)], # dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# ) # )
# router = APIRouter( # router = APIRouter(
# prefix="/api/v1", # prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)], # dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# ) # )
# router.include_router(auth.router, tags=[Tags.AUTH]) # router.include_router(auth.router, tags=[Tags.AUTH])
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD]) # 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 모델 정의 Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리 - Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리 - Image: 업로드된 이미지 URL 관리
""" """
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Index, Integer, String, Text, func from sqlalchemy import DateTime, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song from app.song.models import Song
from app.video.models import Video from app.video.models import Video
class Project(Base): class Project(Base):
""" """
프로젝트 테이블 (사용자 입력 이력) 프로젝트 테이블 (사용자 입력 이력)
영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다. 영상 제작 요청의 시작점으로, 고객 정보와 지역 정보를 저장합니다.
하위 테이블(Lyric, Song, Video) 부모 테이블 역할을 합니다. 하위 테이블(Lyric, Song, Video) 부모 테이블 역할을 합니다.
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수) store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 ) region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36) task_id: 작업 고유 식별자 (UUID 형식, 36)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식) detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
Relationships: Relationships:
lyrics: 생성된 가사 목록 lyrics: 생성된 가사 목록
songs: 생성된 노래 목록 songs: 생성된 노래 목록
videos: 최종 영상 결과 목록 videos: 최종 영상 결과 목록
""" """
__tablename__ = "project" __tablename__ = "project"
__table_args__ = ( __table_args__ = (
Index("idx_project_task_id", "task_id"), Index("idx_project_task_id", "task_id"),
Index("idx_project_store_name", "store_name"), Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"), Index("idx_project_region", "region"),
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci", "mysql_collate": "utf8mb4_unicode_ci",
}, },
) )
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, Integer,
primary_key=True, primary_key=True,
nullable=False, nullable=False,
autoincrement=True, autoincrement=True,
comment="고유 식별자", comment="고유 식별자",
) )
store_name: Mapped[str] = mapped_column( store_name: Mapped[str] = mapped_column(
String(255), String(255),
nullable=False, nullable=False,
index=True, index=True,
comment="가게명", comment="가게명",
) )
region: Mapped[str] = mapped_column( region: Mapped[str] = mapped_column(
String(100), String(100),
nullable=False, nullable=False,
index=True, index=True,
comment="지역명 (예: 군산)", comment="지역명 (예: 군산)",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)", comment="프로젝트 작업 고유 식별자 (UUID)",
) )
detail_region_info: Mapped[Optional[str]] = mapped_column( detail_region_info: Mapped[Optional[str]] = mapped_column(
Text, Text,
nullable=True, nullable=True,
comment="상세 지역 정보", comment="상세 지역 정보",
) )
language: Mapped[str] = mapped_column( language: Mapped[str] = mapped_column(
String(50), String(50),
nullable=False, nullable=False,
default="Korean", default="Korean",
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
server_default=func.now(), server_default=func.now(),
comment="생성 일시", comment="생성 일시",
) )
# Relationships # Relationships
lyrics: Mapped[List["Lyric"]] = relationship( lyrics: Mapped[List["Lyric"]] = relationship(
"Lyric", "Lyric",
back_populates="project", back_populates="project",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
songs: Mapped[List["Song"]] = relationship( songs: Mapped[List["Song"]] = relationship(
"Song", "Song",
back_populates="project", back_populates="project",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
videos: Mapped[List["Video"]] = relationship( videos: Mapped[List["Video"]] = relationship(
"Video", "Video",
back_populates="project", back_populates="project",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="selectin", lazy="selectin",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str: def truncate(value: str | None, max_len: int = 10) -> str:
if value is None: if value is None:
return "None" return "None"
return (value[:max_len] + "...") if len(value) > max_len else value return (value[:max_len] + "...") if len(value) > max_len else value
return ( return (
f"<Project(" f"<Project("
f"id={self.id}, " f"id={self.id}, "
f"store_name='{self.store_name}', " f"store_name='{self.store_name}', "
f"task_id='{truncate(self.task_id)}'" f"task_id='{truncate(self.task_id)}'"
f")>" f")>"
) )
class Image(Base): class Image(Base):
""" """
업로드 이미지 테이블 업로드 이미지 테이블
사용자가 업로드한 이미지의 URL을 저장합니다. 사용자가 업로드한 이미지의 URL을 저장합니다.
독립적으로 관리되며 Project와 직접적인 관계가 없습니다. 독립적으로 관리되며 Project와 직접적인 관계가 없습니다.
Attributes: Attributes:
id: 고유 식별자 (자동 증가) id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID) task_id: 이미지 업로드 작업 고유 식별자 (UUID)
img_name: 이미지명 img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로) img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정) created_at: 생성 일시 (자동 설정)
""" """
__tablename__ = "image" __tablename__ = "image"
__table_args__ = ( __table_args__ = (
{ {
"mysql_engine": "InnoDB", "mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4", "mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci", "mysql_collate": "utf8mb4_unicode_ci",
}, },
) )
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, Integer,
primary_key=True, primary_key=True,
nullable=False, nullable=False,
autoincrement=True, autoincrement=True,
comment="고유 식별자", comment="고유 식별자",
) )
task_id: Mapped[str] = mapped_column( task_id: Mapped[str] = mapped_column(
String(36), String(36),
nullable=False, nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)", comment="이미지 업로드 작업 고유 식별자 (UUID)",
) )
img_name: Mapped[str] = mapped_column( img_name: Mapped[str] = mapped_column(
String(255), String(255),
nullable=False, nullable=False,
comment="이미지명", comment="이미지명",
) )
img_url: Mapped[str] = mapped_column( img_url: Mapped[str] = mapped_column(
String(2048), String(2048),
nullable=False, nullable=False,
comment="이미지 URL (blob, CDN 경로)", comment="이미지 URL (blob, CDN 경로)",
) )
img_order: Mapped[int] = mapped_column( img_order: Mapped[int] = mapped_column(
Integer, Integer,
nullable=False, nullable=False,
default=0, default=0,
comment="이미지 순서", comment="이미지 순서",
) )
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
nullable=False, nullable=False,
server_default=func.now(), server_default=func.now(),
comment="생성 일시", comment="생성 일시",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
task_id_str = ( task_id_str = (
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
) )
img_name_str = ( img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name (self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
) )
return ( return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>" 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 typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class AttributeInfo(BaseModel): class AttributeInfo(BaseModel):
"""음악 속성 정보""" """음악 속성 정보"""
genre: str = Field(..., description="음악 장르") genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일") vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포") tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기") mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel): class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마""" """이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL") url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel): class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)""" """생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명") customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명") region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보") attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field( language: str = Field(
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
) )
class GenerateRequest(GenerateRequestInfo): class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body) """기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다. 이미지 없이 프로젝트 정보만 전달합니다.
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": { "attribute": {
"genre": "K-Pop", "genre": "K-Pop",
"vocal": "Raspy", "vocal": "Raspy",
"tempo": "110 BPM", "tempo": "110 BPM",
"mood": "happy", "mood": "happy",
}, },
"language": "Korean", "language": "Korean",
} }
} }
) )
class GenerateUrlsRequest(GenerateRequestInfo): class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body) """URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"customer_name": "스테이 머뭄", "customer_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": { "attribute": {
"genre": "K-Pop", "genre": "K-Pop",
"vocal": "Raspy", "vocal": "Raspy",
"tempo": "110 BPM", "tempo": "110 BPM",
"mood": "happy", "mood": "happy",
}, },
"language": "Korean", "language": "Korean",
"images": [ "images": [
{"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"},
], ],
} }
} }
) )
images: list[GenerateRequestImg] = Field( images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1 ..., description="이미지 URL 목록", min_length=1
) )
class GenerateUploadResponse(BaseModel): class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마""" """파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field( status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태" ..., description="작업 상태"
) )
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수") uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel): class GenerateResponse(BaseModel):
"""생성 응답 스키마""" """생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field( status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태" ..., description="작업 상태"
) )
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel): class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마""" """크롤링 요청 스키마"""
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "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": "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") url: str = Field(..., description="네이버 지도 장소 URL")
class ProcessedInfo(BaseModel): class ProcessedInfo(BaseModel):
"""가공된 장소 정보 스키마""" """가공된 장소 정보 스키마"""
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)") customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)") region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)") detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
class MarketingAnalysis(BaseModel): class MarketingAnalysis(BaseModel):
"""마케팅 분석 결과 스키마""" """마케팅 분석 결과 스키마"""
report: str = Field(..., description="마케팅 분석 리포트") report: str = Field(..., description="마케팅 분석 리포트")
tags: list[str] = Field(default_factory=list, description="추천 태그 목록") tags: list[str] = Field(default_factory=list, description="추천 태그 목록")
facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록") facilities: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
class CrawlingResponse(BaseModel): class CrawlingResponse(BaseModel):
"""크롤링 응답 스키마""" """크롤링 응답 스키마"""
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록") image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
image_count: int = Field(..., description="이미지 개수") image_count: int = Field(..., description="이미지 개수")
processed_info: Optional[ProcessedInfo] = Field( processed_info: Optional[ProcessedInfo] = Field(
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)" None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
) )
marketing_analysis: Optional[MarketingAnalysis] = Field( marketing_analysis: Optional[MarketingAnalysis] = Field(
None, description="마케팅 분석 결과 (report, tags, facilities)" None, description="마케팅 분석 결과 (report, tags, facilities)"
) )
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
"""에러 응답 스키마""" """에러 응답 스키마"""
success: bool = Field(default=False, description="요청 성공 여부") success: bool = Field(default=False, description="요청 성공 여부")
error_code: str = Field(..., description="에러 코드") error_code: str = Field(..., description="에러 코드")
message: str = Field(..., description="에러 메시지") message: str = Field(..., description="에러 메시지")
detail: Optional[str] = Field(None, description="상세 에러 정보") detail: Optional[str] = Field(None, description="상세 에러 정보")
# ============================================================================= # =============================================================================
# Image Upload Schemas # Image Upload Schemas
# ============================================================================= # =============================================================================
class ImageUrlItem(BaseModel): class ImageUrlItem(BaseModel):
"""이미지 URL 아이템 스키마""" """이미지 URL 아이템 스키마"""
url: str = Field(..., description="외부 이미지 URL") url: str = Field(..., description="외부 이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadRequest(BaseModel): class ImageUploadRequest(BaseModel):
"""이미지 업로드 요청 스키마 (JSON body 부분) """이미지 업로드 요청 스키마 (JSON body 부분)
URL 이미지 목록을 전달합니다. URL 이미지 목록을 전달합니다.
바이너리 파일은 multipart/form-data로 별도 전달됩니다. 바이너리 파일은 multipart/form-data로 별도 전달됩니다.
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"images": [ "images": [
{"url": "https://example.com/images/image_001.jpg"}, {"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"}, {"url": "https://example.com/images/image_002.jpg", "name": "외관"},
] ]
} }
} }
) )
images: Optional[list[ImageUrlItem]] = Field( images: Optional[list[ImageUrlItem]] = Field(
None, description="외부 이미지 URL 목록" None, description="외부 이미지 URL 목록"
) )
class ImageUploadResultItem(BaseModel): class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템""" """업로드된 이미지 결과 아이템"""
id: int = Field(..., description="이미지 ID") id: int = Field(..., description="이미지 ID")
img_name: str = Field(..., description="이미지명") img_name: str = Field(..., description="이미지명")
img_url: str = Field(..., description="이미지 URL") img_url: str = Field(..., description="이미지 URL")
img_order: int = Field(..., description="이미지 순서") img_order: int = Field(..., description="이미지 순서")
source: Literal["url", "file", "blob"] = Field( source: Literal["url", "file", "blob"] = Field(
..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)" ..., description="이미지 소스 (url: 외부 URL, file: 로컬 서버, blob: Azure Blob)"
) )
class ImageUploadResponse(BaseModel): class ImageUploadResponse(BaseModel):
"""이미지 업로드 응답 스키마""" """이미지 업로드 응답 스키마"""
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"total_count": 3, "total_count": 3,
"url_count": 2, "url_count": 2,
"file_count": 1, "file_count": 1,
"saved_count": 3, "saved_count": 3,
"images": [ "images": [
{ {
"id": 1, "id": 1,
"img_name": "외관", "img_name": "외관",
"img_url": "https://example.com/images/image_001.jpg", "img_url": "https://example.com/images/image_001.jpg",
"img_order": 0, "img_order": 0,
"source": "url", "source": "url",
}, },
{ {
"id": 2, "id": 2,
"img_name": "내부", "img_name": "내부",
"img_url": "https://example.com/images/image_002.jpg", "img_url": "https://example.com/images/image_002.jpg",
"img_order": 1, "img_order": 1,
"source": "url", "source": "url",
}, },
{ {
"id": 3, "id": 3,
"img_name": "uploaded_image.jpg", "img_name": "uploaded_image.jpg",
"img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", "img_url": "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
"img_order": 2, "img_order": 2,
"source": "file", "source": "file",
}, },
], ],
"image_urls": [ "image_urls": [
"https://example.com/images/image_001.jpg", "https://example.com/images/image_001.jpg",
"https://example.com/images/image_002.jpg", "https://example.com/images/image_002.jpg",
"/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg", "/media/image/2024-01-15/0694b716-dbff-7219-8000-d08cb5fce431/uploaded_image_002.jpg",
], ],
} }
} }
) )
task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)") task_id: str = Field(..., description="작업 고유 식별자 (새로 생성된 UUID7)")
total_count: int = Field(..., description="총 업로드된 이미지 개수") total_count: int = Field(..., description="총 업로드된 이미지 개수")
url_count: int = Field(..., description="URL로 등록된 이미지 개수") url_count: int = Field(..., description="URL로 등록된 이미지 개수")
file_count: int = Field(..., description="파일로 업로드된 이미지 개수") file_count: int = Field(..., description="파일로 업로드된 이미지 개수")
saved_count: int = Field(..., description="Image 테이블에 저장된 row 수") saved_count: int = Field(..., description="Image 테이블에 저장된 row 수")
images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록") images: list[ImageUploadResultItem] = Field(..., description="업로드된 이미지 목록")
image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록") image_urls: list[str] = Field(..., description="Image 테이블에 저장된 현재 task_id의 이미지 URL 목록")

View File

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

View File

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

View File

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

View File

@ -1,30 +1,30 @@
import pytest import pytest
from sqlalchemy import text from sqlalchemy import text
from app.database.session import AsyncSessionLocal, engine from app.database.session import AsyncSessionLocal, engine
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_database_connection(): async def test_database_connection():
"""데이터베이스 연결 테스트""" """데이터베이스 연결 테스트"""
async with engine.begin() as connection: async with engine.begin() as connection:
result = await connection.execute(text("SELECT 1")) result = await connection.execute(text("SELECT 1"))
assert result.scalar() == 1 assert result.scalar() == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_session_creation(): async def test_session_creation():
"""세션 생성 테스트""" """세션 생성 테스트"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT 1")) result = await session.execute(text("SELECT 1"))
assert result.scalar() == 1 assert result.scalar() == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_database_version(): async def test_database_version():
"""MySQL 버전 확인 테스트""" """MySQL 버전 확인 테스트"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute(text("SELECT VERSION()")) result = await session.execute(text("SELECT VERSION()"))
version = result.scalar() version = result.scalar()
assert version is not None assert version is not None
print(f"MySQL Version: {version}") 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 sqladmin import ModelView
from app.lyric.models import Lyric from app.lyric.models import Lyric
class LyricAdmin(ModelView, model=Lyric): class LyricAdmin(ModelView, model=Lyric):
name = "가사" name = "가사"
name_plural = "가사 목록" name_plural = "가사 목록"
icon = "fa-solid fa-music" icon = "fa-solid fa-music"
category = "가사 관리" category = "가사 관리"
page_size = 20 page_size = 20
column_list = [ column_list = [
"id", "id",
"project_id", "project_id",
"task_id", "task_id",
"status", "status",
"language", "language",
"created_at", "created_at",
] ]
column_details_list = [ column_details_list = [
"id", "id",
"project_id", "project_id",
"task_id", "task_id",
"status", "status",
"language", "language",
"lyric_prompt", "lyric_prompt",
"lyric_result", "lyric_result",
"created_at", "created_at",
] ]
# 폼(생성/수정)에서 제외 # 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "songs", "videos"] form_excluded_columns = ["created_at", "songs", "videos"]
column_searchable_list = [ column_searchable_list = [
Lyric.task_id, Lyric.task_id,
Lyric.status, Lyric.status,
Lyric.language, Lyric.language,
] ]
column_default_sort = (Lyric.created_at, True) # True: DESC (최신순) column_default_sort = (Lyric.created_at, True) # True: DESC (최신순)
column_sortable_list = [ column_sortable_list = [
Lyric.id, Lyric.id,
Lyric.project_id, Lyric.project_id,
Lyric.status, Lyric.status,
Lyric.language, Lyric.language,
Lyric.created_at, Lyric.created_at,
] ]
column_labels = { column_labels = {
"id": "ID", "id": "ID",
"project_id": "프로젝트 ID", "project_id": "프로젝트 ID",
"task_id": "작업 ID", "task_id": "작업 ID",
"status": "상태", "status": "상태",
"language": "언어", "language": "언어",
"lyric_prompt": "프롬프트", "lyric_prompt": "프롬프트",
"lyric_result": "생성 결과", "lyric_result": "생성 결과",
"created_at": "생성일시", "created_at": "생성일시",
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,24 @@
from uuid import UUID from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel from sqlmodel import SQLModel
class BaseService: class BaseService:
def __init__(self, model, session: AsyncSession): def __init__(self, model, session: AsyncSession):
self.model = model self.model = model
self.session = session self.session = session
async def _get(self, id: UUID): async def _get(self, id: UUID):
return await self.session.get(self.model, id) return await self.session.get(self.model, id)
async def _add(self, entity): async def _add(self, entity):
self.session.add(entity) self.session.add(entity)
await self.session.commit() await self.session.commit()
await self.session.refresh(entity) await self.session.refresh(entity)
return entity return entity
async def _update(self, entity): async def _update(self, entity):
return await self._add(entity) return await self._add(entity)
async def _delete(self, entity): async def _delete(self, entity):
await self.session.delete(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 Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다. 가사 생성 관련 백그라운드 태스크를 정의합니다.
""" """
from sqlalchemy import select import logging
import traceback
from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric from sqlalchemy import select
from app.utils.chatgpt_prompt import ChatgptService from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
async def generate_lyric_background( from app.lyric.models import Lyric
task_id: str, from app.utils.chatgpt_prompt import ChatgptService
prompt: str,
language: str, # 로거 설정
) -> None: logger = logging.getLogger(__name__)
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
Args: async def _update_lyric_status(
task_id: 프로젝트 task_id task_id: str,
prompt: ChatGPT에 전달할 프롬프트 status: str,
language: 가사 언어 result: str | None = None,
""" ) -> bool:
print(f"[generate_lyric_background] START - task_id: {task_id}") """Lyric 테이블의 상태를 업데이트합니다.
try: Args:
# ChatGPT 서비스 초기화 (프롬프트는 이미 생성되어 있음) task_id: 프로젝트 task_id
service = ChatgptService( status: 변경할 상태 ("processing", "completed", "failed")
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 result: 가사 결과 또는 에러 메시지
region="",
detail_region_info="", Returns:
language=language, bool: 업데이트 성공 여부
) """
try:
# ChatGPT를 통해 가사 생성 async with BackgroundSessionLocal() as session:
print(f"[generate_lyric_background] ChatGPT generation started - task_id: {task_id}") query_result = await session.execute(
result = await service.generate(prompt=prompt) select(Lyric)
print(f"[generate_lyric_background] ChatGPT generation completed - task_id: {task_id}") .where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
# 실패 응답 검사 (ERROR 또는 ChatGPT 거부 응답) .limit(1)
failure_patterns = [ )
"ERROR:", lyric = query_result.scalar_one_or_none()
"I'm sorry",
"I cannot", if lyric:
"I can't", lyric.status = status
"I apologize", if result is not None:
"I'm unable", lyric.lyric_result = result
"I am unable", await session.commit()
"I'm not able", logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
"I am not able", print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
] return True
is_failure = any( else:
pattern.lower() in result.lower() for pattern in failure_patterns 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
# Lyric 테이블 업데이트 (백그라운드 전용 세션 사용)
async with BackgroundSessionLocal() as session: except SQLAlchemyError as e:
query_result = await session.execute( logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
select(Lyric) print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
.where(Lyric.task_id == task_id) return False
.order_by(Lyric.created_at.desc()) except Exception as e:
.limit(1) 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}")
lyric = query_result.scalar_one_or_none() return False
if lyric:
if is_failure: async def generate_lyric_background(
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, error: {result}") task_id: str,
lyric.status = "failed" prompt: str,
lyric.lyric_result = result language: str,
else: ) -> None:
print(f"[generate_lyric_background] SUCCESS - task_id: {task_id}") """백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
lyric.status = "completed"
lyric.lyric_result = result Args:
task_id: 프로젝트 task_id
await session.commit() prompt: ChatGPT에 전달할 프롬프트
else: language: 가사 언어
print(f"[generate_lyric_background] Lyric NOT FOUND in DB - task_id: {task_id}") """
import time
except Exception as e:
print(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e}") task_start = time.perf_counter()
# 실패 시 Lyric 테이블 업데이트 logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
async with BackgroundSessionLocal() as session: print(f"[generate_lyric_background] ========== START ==========")
query_result = await session.execute( print(f"[generate_lyric_background] task_id: {task_id}")
select(Lyric) print(f"[generate_lyric_background] language: {language}")
.where(Lyric.task_id == task_id) print(f"[generate_lyric_background] prompt length: {len(prompt)}")
.order_by(Lyric.created_at.desc())
.limit(1) try:
) # ========== Step 1: ChatGPT 서비스 초기화 ==========
lyric = query_result.scalar_one_or_none() step1_start = time.perf_counter()
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
if lyric:
lyric.status = "failed" service = ChatgptService(
lyric.lyric_result = f"Error: {str(e)}" customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
await session.commit() region="",
print(f"[generate_lyric_background] FAILED - task_id: {task_id}, status updated to failed") 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 sqladmin import ModelView
from app.song.models import Song from app.song.models import Song
class SongAdmin(ModelView, model=Song): class SongAdmin(ModelView, model=Song):
name = "노래" name = "노래"
name_plural = "노래 목록" name_plural = "노래 목록"
icon = "fa-solid fa-headphones" icon = "fa-solid fa-headphones"
category = "노래 관리" category = "노래 관리"
page_size = 20 page_size = 20
column_list = [ column_list = [
"id", "id",
"project_id", "project_id",
"lyric_id", "lyric_id",
"task_id", "task_id",
"suno_task_id", "suno_task_id",
"status", "status",
"language", "language",
"created_at", "created_at",
] ]
column_details_list = [ column_details_list = [
"id", "id",
"project_id", "project_id",
"lyric_id", "lyric_id",
"task_id", "task_id",
"suno_task_id", "suno_task_id",
"status", "status",
"language", "language",
"song_prompt", "song_prompt",
"song_result_url", "song_result_url",
"created_at", "created_at",
] ]
# 폼(생성/수정)에서 제외 # 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at", "videos"] form_excluded_columns = ["created_at", "videos"]
column_searchable_list = [ column_searchable_list = [
Song.task_id, Song.task_id,
Song.suno_task_id, Song.suno_task_id,
Song.status, Song.status,
Song.language, Song.language,
] ]
column_default_sort = (Song.created_at, True) # True: DESC (최신순) column_default_sort = (Song.created_at, True) # True: DESC (최신순)
column_sortable_list = [ column_sortable_list = [
Song.id, Song.id,
Song.project_id, Song.project_id,
Song.lyric_id, Song.lyric_id,
Song.status, Song.status,
Song.language, Song.language,
Song.created_at, Song.created_at,
] ]
column_labels = { column_labels = {
"id": "ID", "id": "ID",
"project_id": "프로젝트 ID", "project_id": "프로젝트 ID",
"lyric_id": "가사 ID", "lyric_id": "가사 ID",
"task_id": "작업 ID", "task_id": "작업 ID",
"suno_task_id": "Suno 작업 ID", "suno_task_id": "Suno 작업 ID",
"status": "상태", "status": "상태",
"language": "언어", "language": "언어",
"song_prompt": "프롬프트", "song_prompt": "프롬프트",
"song_result_url": "결과 URL", "song_result_url": "결과 URL",
"created_at": "생성일시", "created_at": "생성일시",
} }

View File

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

View File

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

View File

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

View File

@ -1,24 +1,24 @@
from uuid import UUID from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import SQLModel from sqlmodel import SQLModel
class BaseService: class BaseService:
def __init__(self, model, session: AsyncSession): def __init__(self, model, session: AsyncSession):
self.model = model self.model = model
self.session = session self.session = session
async def _get(self, id: UUID): async def _get(self, id: UUID):
return await self.session.get(self.model, id) return await self.session.get(self.model, id)
async def _add(self, entity): async def _add(self, entity):
self.session.add(entity) self.session.add(entity)
await self.session.commit() await self.session.commit()
await self.session.refresh(entity) await self.session.refresh(entity)
return entity return entity
async def _update(self, entity): async def _update(self, entity):
return await self._add(entity) return await self._add(entity)
async def _delete(self, entity): async def _delete(self, entity):
await self.session.delete(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 Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다. 노래 생성 관련 백그라운드 태스크를 정의합니다.
""" """
from datetime import date import logging
from pathlib import Path import traceback
from datetime import date
import aiofiles from pathlib import Path
import httpx
from sqlalchemy import select import aiofiles
import httpx
from app.database.session import BackgroundSessionLocal from sqlalchemy import select
from app.song.models import Song from sqlalchemy.exc import SQLAlchemyError
from app.utils.common import generate_task_id
from app.utils.upload_blob_as_request import AzureBlobUploader from app.database.session import BackgroundSessionLocal
from config import prj_settings from app.song.models import Song
from app.utils.common import generate_task_id
from app.utils.upload_blob_as_request import AzureBlobUploader
async def download_and_save_song( from config import prj_settings
task_id: str,
audio_url: str, # 로거 설정
store_name: str, logger = logging.getLogger(__name__)
) -> None:
"""백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다. # HTTP 요청 설정
REQUEST_TIMEOUT = 120.0 # 초
Args:
task_id: 프로젝트 task_id
audio_url: 다운로드할 오디오 URL async def _update_song_status(
store_name: 저장할 파일명에 사용할 업체명 task_id: str,
""" status: str,
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") song_url: str | None = None,
try: suno_task_id: str | None = None,
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 duration: float | None = None,
today = date.today().strftime("%Y-%m-%d") ) -> bool:
unique_id = await generate_task_id() """Song 테이블의 상태를 업데이트합니다.
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join( Args:
c for c in store_name if c.isalnum() or c in (" ", "_", "-") task_id: 프로젝트 task_id
).strip() status: 변경할 상태 ("processing", "completed", "failed")
safe_store_name = safe_store_name or "song" song_url: 노래 URL
file_name = f"{safe_store_name}.mp3" suno_task_id: Suno task ID (선택)
duration: 노래 길이 (선택)
# 절대 경로 생성
media_dir = Path("media") / "song" / today / unique_id Returns:
media_dir.mkdir(parents=True, exist_ok=True) bool: 업데이트 성공 여부
file_path = media_dir / file_name """
print(f"[download_and_save_song] Directory created - path: {file_path}") try:
async with BackgroundSessionLocal() as session:
# 오디오 파일 다운로드 if suno_task_id:
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") query_result = await session.execute(
async with httpx.AsyncClient() as client: select(Song)
response = await client.get(audio_url, timeout=60.0) .where(Song.suno_task_id == suno_task_id)
response.raise_for_status() .order_by(Song.created_at.desc())
.limit(1)
async with aiofiles.open(str(file_path), "wb") as f: )
await f.write(response.content) else:
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") query_result = await session.execute(
select(Song)
# 프론트엔드에서 접근 가능한 URL 생성 .where(Song.task_id == task_id)
relative_path = f"/media/song/{today}/{unique_id}/{file_name}" .order_by(Song.created_at.desc())
base_url = f"http://{prj_settings.PROJECT_DOMAIN}" .limit(1)
file_url = f"{base_url}{relative_path}" )
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
song = query_result.scalar_one_or_none()
# Song 테이블 업데이트 (새 세션 사용)
async with BackgroundSessionLocal() as session: if song:
# 여러 개 있을 경우 가장 최근 것 선택 song.status = status
result = await session.execute( if song_url is not None:
select(Song) song.song_result_url = song_url
.where(Song.task_id == task_id) if duration is not None:
.order_by(Song.created_at.desc()) song.duration = duration
.limit(1) await session.commit()
) logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
song = result.scalar_one_or_none() print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
return True
if song: else:
song.status = "completed" logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
song.song_result_url = file_url print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
await session.commit() return False
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}, status: completed")
else: except SQLAlchemyError as e:
print(f"[download_and_save_song] Song NOT FOUND in DB - task_id: {task_id}") 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}")
except Exception as e: return False
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") except Exception as e:
# 실패 시 Song 테이블 업데이트 logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
async with BackgroundSessionLocal() as session: print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
# 여러 개 있을 경우 가장 최근 것 선택 return False
result = await session.execute(
select(Song)
.where(Song.task_id == task_id) async def _download_audio(url: str, task_id: str) -> bytes:
.order_by(Song.created_at.desc()) """URL에서 오디오 파일을 다운로드합니다.
.limit(1)
) Args:
song = result.scalar_one_or_none() url: 다운로드할 URL
task_id: 로그용 task_id
if song:
song.status = "failed" Returns:
await session.commit() bytes: 다운로드한 파일 내용
print(f"[download_and_save_song] FAILED - task_id: {task_id}, status updated to failed")
Raises:
httpx.HTTPError: 다운로드 실패
async def download_and_upload_song_to_blob( """
task_id: str, logger.info(f"[Download] Downloading - task_id: {task_id}")
audio_url: str, print(f"[Download] Downloading - task_id: {task_id}")
store_name: str,
) -> None: async with httpx.AsyncClient() as client:
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
Args:
task_id: 프로젝트 task_id logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
audio_url: 다운로드할 오디오 URL print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
store_name: 저장할 파일명에 사용할 업체명 return response.content
"""
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None async def download_and_save_song(
task_id: str,
try: audio_url: str,
# 파일명에 사용할 수 없는 문자 제거 store_name: str,
safe_store_name = "".join( ) -> None:
c for c in store_name if c.isalnum() or c in (" ", "_", "-") """백그라운드에서 노래를 다운로드하고 Song 테이블을 업데이트합니다.
).strip()
safe_store_name = safe_store_name or "song" Args:
file_name = f"{safe_store_name}.mp3" task_id: 프로젝트 task_id
audio_url: 다운로드할 오디오 URL
# 임시 저장 경로 생성 store_name: 저장할 파일명에 사용할 업체명
temp_dir = Path("media") / "temp" / task_id """
temp_dir.mkdir(parents=True, exist_ok=True) logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path = temp_dir / file_name print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
try:
# 오디오 파일 다운로드 # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") today = date.today().strftime("%Y-%m-%d")
async with httpx.AsyncClient() as client: unique_id = await generate_task_id()
response = await client.get(audio_url, timeout=60.0) # 파일명에 사용할 수 없는 문자 제거
response.raise_for_status() safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-")
async with aiofiles.open(str(temp_file_path), "wb") as f: ).strip()
await f.write(response.content) safe_store_name = safe_store_name or "song"
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") file_name = f"{safe_store_name}.mp3"
# Azure Blob Storage에 업로드 # 절대 경로 생성
uploader = AzureBlobUploader(task_id=task_id) media_dir = Path("media") / "song" / today / unique_id
upload_success = await uploader.upload_music(file_path=str(temp_file_path)) media_dir.mkdir(parents=True, exist_ok=True)
file_path = media_dir / file_name
if not upload_success: logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
raise Exception("Azure Blob Storage 업로드 실패") print(f"[download_and_save_song] Directory created - path: {file_path}")
# SAS 토큰이 제외된 public_url 사용 # 오디오 파일 다운로드
blob_url = uploader.public_url logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
# Song 테이블 업데이트 (새 세션 사용) content = await _download_audio(audio_url, task_id)
async with BackgroundSessionLocal() as session:
# 여러 개 있을 경우 가장 최근 것 선택 async with aiofiles.open(str(file_path), "wb") as f:
result = await session.execute( await f.write(content)
select(Song)
.where(Song.task_id == task_id) logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
.order_by(Song.created_at.desc()) print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
.limit(1)
) # 프론트엔드에서 접근 가능한 URL 생성
song = result.scalar_one_or_none() relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
if song: file_url = f"{base_url}{relative_path}"
song.status = "completed" logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
song.song_result_url = blob_url print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
await session.commit()
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}, status: completed") # Song 테이블 업데이트
else: await _update_song_status(task_id, "completed", file_url)
print(f"[download_and_upload_song_to_blob] Song NOT FOUND in DB - task_id: {task_id}") logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
except Exception as e:
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") except httpx.HTTPError as e:
# 실패 시 Song 테이블 업데이트 logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
async with BackgroundSessionLocal() as session: print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
result = await session.execute( traceback.print_exc()
select(Song) await _update_song_status(task_id, "failed")
.where(Song.task_id == task_id)
.order_by(Song.created_at.desc()) except SQLAlchemyError as e:
.limit(1) 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}")
song = result.scalar_one_or_none() traceback.print_exc()
await _update_song_status(task_id, "failed")
if song:
song.status = "failed" except Exception as e:
await session.commit() logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] FAILED - task_id: {task_id}, status updated to failed") print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
finally: await _update_song_status(task_id, "failed")
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
try: async def download_and_upload_song_to_blob(
temp_file_path.unlink() task_id: str,
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") audio_url: str,
except Exception as e: store_name: str,
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") ) -> None:
"""백그라운드에서 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id Args:
if temp_dir.exists(): task_id: 프로젝트 task_id
try: audio_url: 다운로드할 오디오 URL
temp_dir.rmdir() store_name: 저장할 파일명에 사용할 업체명
except Exception: """
pass # 디렉토리가 비어있지 않으면 무시 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
async def download_and_upload_song_by_suno_task_id(
suno_task_id: str, try:
audio_url: str, # 파일명에 사용할 수 없는 문자 제거
store_name: str, safe_store_name = "".join(
duration: float | None = None, c for c in store_name if c.isalnum() or c in (" ", "_", "-")
) -> None: ).strip()
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다. safe_store_name = safe_store_name or "song"
file_name = f"{safe_store_name}.mp3"
Args:
suno_task_id: Suno API 작업 ID # 임시 저장 경로 생성
audio_url: 다운로드할 오디오 URL temp_dir = Path("media") / "temp" / task_id
store_name: 저장할 파일명에 사용할 업체명 temp_dir.mkdir(parents=True, exist_ok=True)
duration: 노래 재생 시간 () 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_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
temp_file_path: Path | None = None
task_id: str | None = None # 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
try: print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
# suno_task_id로 Song 조회하여 task_id 가져오기
async with BackgroundSessionLocal() as session: content = await _download_audio(audio_url, task_id)
result = await session.execute(
select(Song) async with aiofiles.open(str(temp_file_path), "wb") as f:
.where(Song.suno_task_id == suno_task_id) await f.write(content)
.order_by(Song.created_at.desc())
.limit(1) 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}")
song = result.scalar_one_or_none()
# Azure Blob Storage에 업로드
if not song: uploader = AzureBlobUploader(task_id=task_id)
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") upload_success = await uploader.upload_music(file_path=str(temp_file_path))
return
if not upload_success:
task_id = song.task_id raise Exception("Azure Blob Storage 업로드 실패")
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
# SAS 토큰이 제외된 public_url 사용
# 파일명에 사용할 수 없는 문자 제거 blob_url = uploader.public_url
safe_store_name = "".join( logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
c for c in store_name if c.isalnum() or c in (" ", "_", "-") print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
).strip()
safe_store_name = safe_store_name or "song" # Song 테이블 업데이트
file_name = f"{safe_store_name}.mp3" 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}")
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True) except httpx.HTTPError as e:
temp_file_path = temp_dir / file_name logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") 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")
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: except SQLAlchemyError as e:
response = await client.get(audio_url, timeout=60.0) logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
response.raise_for_status() print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
async with aiofiles.open(str(temp_file_path), "wb") as f: await _update_song_status(task_id, "failed")
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}") except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# Azure Blob Storage에 업로드 print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
uploader = AzureBlobUploader(task_id=task_id) traceback.print_exc()
upload_success = await uploader.upload_music(file_path=str(temp_file_path)) await _update_song_status(task_id, "failed")
if not upload_success: finally:
raise Exception("Azure Blob Storage 업로드 실패") # 임시 파일 삭제
if temp_file_path and temp_file_path.exists():
# SAS 토큰이 제외된 public_url 사용 try:
blob_url = uploader.public_url temp_file_path.unlink()
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") 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}")
# Song 테이블 업데이트 (새 세션 사용) except Exception as e:
async with BackgroundSessionLocal() as session: logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
result = await session.execute( print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
select(Song)
.where(Song.suno_task_id == suno_task_id) # 임시 디렉토리 삭제 시도
.order_by(Song.created_at.desc()) temp_dir = Path("media") / "temp" / task_id
.limit(1) if temp_dir.exists():
) try:
song = result.scalar_one_or_none() temp_dir.rmdir()
except Exception:
if song: pass # 디렉토리가 비어있지 않으면 무시
song.status = "completed"
song.song_result_url = blob_url
if duration is not None: async def download_and_upload_song_by_suno_task_id(
song.duration = duration suno_task_id: str,
await session.commit() audio_url: str,
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, status: completed, duration: {duration}") store_name: str,
else: duration: float | None = None,
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND in DB - suno_task_id: {suno_task_id}") ) -> None:
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
except Exception as e:
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") Args:
# 실패 시 Song 테이블 업데이트 suno_task_id: Suno API 작업 ID
if task_id: audio_url: 다운로드할 오디오 URL
async with BackgroundSessionLocal() as session: store_name: 저장할 파일명에 사용할 업체명
result = await session.execute( duration: 노래 재생 시간 ()
select(Song) """
.where(Song.suno_task_id == suno_task_id) logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
.order_by(Song.created_at.desc()) print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
.limit(1) temp_file_path: Path | None = None
) task_id: str | None = None
song = result.scalar_one_or_none()
try:
if song: # suno_task_id로 Song 조회하여 task_id 가져오기
song.status = "failed" async with BackgroundSessionLocal() as session:
await session.commit() result = await session.execute(
print(f"[download_and_upload_song_by_suno_task_id] FAILED - suno_task_id: {suno_task_id}, status updated to failed") select(Song)
.where(Song.suno_task_id == suno_task_id)
finally: .order_by(Song.created_at.desc())
# 임시 파일 삭제 .limit(1)
if temp_file_path and temp_file_path.exists(): )
try: song = result.scalar_one_or_none()
temp_file_path.unlink()
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") if not song:
except Exception as e: 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] Failed to delete temp file: {e}") print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return
# 임시 디렉토리 삭제 시도
if task_id: task_id = song.task_id
temp_dir = Path("media") / "temp" / 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}")
if temp_dir.exists(): print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
try:
temp_dir.rmdir() # 파일명에 사용할 수 없는 문자 제거
except Exception: safe_store_name = "".join(
pass # 디렉토리가 비어있지 않으면 무시 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 json
import re import logging
import re
from openai import AsyncOpenAI
from openai import AsyncOpenAI
from config import apikey_settings
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 logger = logging.getLogger(__name__)
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:
# fmt: off
**Core Analysis:** LYRICS_PROMPT_TEMPLATE_ORI = """
- Target customer segments & personas 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
- Unique Selling Propositions (USPs) and competitive differentiators 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:
- Comprehensive competitor landscape analysis (direct & indirect competitors)
- Market positioning assessment **Core Analysis:**
- Target customer segments & personas
**Content Strategy Framework:** - Unique Selling Propositions (USPs) and competitive differentiators
- Seasonal content calendar with trend integration - Comprehensive competitor landscape analysis (direct & indirect competitors)
- Visual storytelling direction (shot-by-shot creative guidance) - Market positioning assessment
- Brand tone & voice guidelines
- Content themes aligned with target audience behaviors **Content Strategy Framework:**
- Seasonal content calendar with trend integration
**SEO & AEO Optimization:** - Visual storytelling direction (shot-by-shot creative guidance)
- Recommended primary and long-tail keywords - Brand tone & voice guidelines
- SEO-optimized taglines and meta descriptions - Content themes aligned with target audience behaviors
- Answer Engine Optimization (AEO) content suggestions
- Local search optimization strategies **SEO & AEO Optimization:**
- Recommended primary and long-tail keywords
**Actionable Recommendations:** - SEO-optimized taglines and meta descriptions
- Content distribution strategy across platforms - Answer Engine Optimization (AEO) content suggestions
- KPI measurement framework - Local search optimization strategies
- Budget allocation recommendations by content type
**Actionable Recommendations:**
콘텐츠 기획(Lyrics, Prompt for SUNO) - Content distribution strategy across platforms
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. - KPI measurement framework
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 [인스타 감성], [사진같은 하루] - Budget allocation recommendations by content type
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 콘텐츠 기획(Lyrics, Prompt for SUNO)
""".strip() 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.
# fmt: on 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 [인스타 감성], [사진같은 하루]
LYRICS_PROMPT_TEMPLATE = """ 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
[ROLE] """.strip()
Content marketing expert and creative songwriter specializing in pension/accommodation services # fmt: on
[INPUT] LYRICS_PROMPT_TEMPLATE = """
- Business Name: {customer_name} [ROLE]
- Region: {region} Content marketing expert and creative songwriter specializing in pension/accommodation services
- Region Details: {detail_region_info}
- Output Language: {language} [INPUT]
- Business Name: {customer_name}
[INTERNAL ANALYSIS - DO NOT OUTPUT] - Region: {region}
Analyze the following internally to inform lyrics creation: - Region Details: {detail_region_info}
- Target customer segments and personas - Output Language: {language}
- Unique Selling Propositions (USPs)
- Regional characteristics and nearby attractions (within 10 min access) [INTERNAL ANALYSIS - DO NOT OUTPUT]
- Seasonal appeal points Analyze the following internally to inform lyrics creation:
- Emotional triggers for the target audience - Target customer segments and personas
- Unique Selling Propositions (USPs)
[LYRICS REQUIREMENTS] - Regional characteristics and nearby attractions (within 10 min access)
1. Must Include Elements: - Seasonal appeal points
- Business name (TRANSLATED or TRANSLITERATED to {language}) - Emotional triggers for the target audience
- Region name (TRANSLATED or TRANSLITERATED to {language})
- Main target audience appeal [LYRICS REQUIREMENTS]
- Nearby famous places or regional characteristics 1. Must Include Elements:
- Business name (TRANSLATED or TRANSLITERATED to {language})
2. Keywords to Incorporate (use language-appropriate trendy expressions): - Region name (TRANSLATED or TRANSLITERATED to {language})
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소 - Main target audience appeal
- English: Instagram vibes, picture-perfect day, healing, travel, getaway - Nearby famous places or regional characteristics
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景 2. Keywords to Incorporate (use language-appropriate trendy expressions):
- Thai: กสวย, ลใจ, เทยว, ายร, วสวย - Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp - English: Instagram vibes, picture-perfect day, healing, travel, getaway
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
3. Structure: - Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
- Length: For 1-minute video (approximately 8-12 lines) - Thai: กสวย, ลใจ, เทยว, ายร, วสวย
- Flow: Verse structure suitable for music - Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
- Rhythm: Natural speech rhythm in the specified language
3. Structure:
4. Tone: - Length: For 1-minute video (approximately 8-12 lines)
- Emotional and heartfelt - Flow: Verse structure suitable for music
- Trendy and viral-friendly - Rhythm: Natural speech rhythm in the specified language
- Relatable to target audience
4. Tone:
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] - Emotional and heartfelt
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS - Trendy and viral-friendly
- ALL lyrics content: {language} ONLY - Relatable to target audience
- 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. [CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc. ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
- ZERO Korean characters (한글) allowed when output language is NOT Korean - ALL lyrics content: {language} ONLY
- ZERO mixing of languages - the entire output must be monolingual in {language} - ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
- This is a NON-NEGOTIABLE requirement - Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
- Any output containing characters from other languages is considered a COMPLETE FAILURE - Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
- Violation of this rule invalidates the entire response - ZERO Korean characters (한글) allowed when output language is NOT Korean
- ZERO mixing of languages - the entire output must be monolingual in {language}
[OUTPUT RULES - STRICTLY ENFORCED] - This is a NON-NEGOTIABLE requirement
- Output lyrics ONLY - Any output containing characters from other languages is considered a COMPLETE FAILURE
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS - Violation of this rule invalidates the entire response
- 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 [OUTPUT RULES - STRICTLY ENFORCED]
- NO titles, descriptions, analysis, or explanations - Output lyrics ONLY
- NO greetings or closing remarks - Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
- NO additional commentary before or after lyrics - ALL names and places MUST be in {language} script/alphabet
- NO line numbers or labels - NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
- Follow the exact format below - NO titles, descriptions, analysis, or explanations
- NO greetings or closing remarks
[OUTPUT FORMAT - SUCCESS] - NO additional commentary before or after lyrics
--- - NO line numbers or labels
[Lyrics ENTIRELY in {language} here - no other language characters allowed] - Follow the exact format below
---
[OUTPUT FORMAT - SUCCESS]
[OUTPUT FORMAT - FAILURE] ---
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason: [Lyrics ENTIRELY in {language} here - no other language characters allowed]
--- ---
ERROR: [Brief reason for failure in English]
--- [OUTPUT FORMAT - FAILURE]
""".strip() If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
# fmt: on ---
ERROR: [Brief reason for failure in English]
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """ ---
[ROLE] """.strip()
Content marketing expert specializing in pension/accommodation services in Korea # fmt: on
[INPUT] MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
- Business Name: {customer_name} [ROLE]
- Region: {region} Content marketing expert specializing in pension/accommodation services in Korea
- Region Details: {detail_region_info}
[INPUT]
[ANALYSIS REQUIREMENTS] - Business Name: {customer_name}
Provide comprehensive marketing analysis including: - Region: {region}
1. Target Customer Segments - Region Details: {detail_region_info}
- Primary and secondary target personas
- Age groups, travel preferences, booking patterns [ANALYSIS REQUIREMENTS]
2. Unique Selling Propositions (USPs) Provide comprehensive marketing analysis including:
- Key differentiators based on location and region details 1. Target Customer Segments
- Competitive advantages - Primary and secondary target personas
3. Regional Characteristics - Age groups, travel preferences, booking patterns
- Nearby attractions and famous places (within 10 min access) 2. Unique Selling Propositions (USPs)
- Local food, activities, and experiences - Key differentiators based on location and region details
- Transportation accessibility - Competitive advantages
4. Seasonal Appeal Points 3. Regional Characteristics
- Best seasons to visit - Nearby attractions and famous places (within 10 min access)
- Seasonal activities and events - Local food, activities, and experiences
- Peak/off-peak marketing opportunities - Transportation accessibility
5. Marketing Keywords 4. Seasonal Appeal Points
- Recommended hashtags and search keywords - Best seasons to visit
- Trending terms relevant to the property - Seasonal activities and events
- Peak/off-peak marketing opportunities
[ADDITIONAL REQUIREMENTS] 5. Marketing Keywords
1. Recommended Tags - Recommended hashtags and search keywords
- Generate 5 recommended hashtags/tags based on the business characteristics - Trending terms relevant to the property
- Tags should be trendy, searchable, and relevant to accommodation marketing
- Return as JSON with key "tags" [ADDITIONAL REQUIREMENTS]
- **MUST be written in Korean (한국어)** 1. Recommended Tags
- Generate 5 recommended hashtags/tags based on the business characteristics
2. Facilities - Tags should be trendy, searchable, and relevant to accommodation marketing
- Based on the business name and region details, identify 5 likely facilities/amenities - Return as JSON with key "tags"
- Consider typical facilities for accommodations in the given region - **MUST be written in Korean (한국어)**
- Examples: 바베큐장, 수영장, 주차장, 와이파이, 주방, 테라스, 정원, etc.
- Return as JSON with key "facilities" [CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
- **MUST be written in Korean (한국어)** ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
- Analysis sections: Korean only
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE] - Tags: Korean only
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어) - This is a NON-NEGOTIABLE requirement
- Analysis sections: Korean only - Any output in English or other languages is considered a FAILURE
- Tags: Korean only - Violation of this rule invalidates the entire response
- Facilities: Korean only
- This is a NON-NEGOTIABLE requirement [OUTPUT RULES - STRICTLY ENFORCED]
- Any output in English or other languages is considered a FAILURE - Output analysis ONLY
- Violation of this rule invalidates the entire response - ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
- NO greetings or closing remarks
[OUTPUT RULES - STRICTLY ENFORCED] - NO additional commentary before or after analysis
- Output analysis ONLY - Follow the exact format below
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
- NO greetings or closing remarks [OUTPUT FORMAT - SUCCESS]
- NO additional commentary before or after analysis ---
- Follow the exact format below ## 타겟 고객 분석
[한국어로 작성된 타겟 고객 분석]
[OUTPUT FORMAT - SUCCESS]
--- ## 핵심 차별점 (USP)
## 타겟 고객 분석 [한국어로 작성된 USP 분석]
[한국어로 작성된 타겟 고객 분석]
## 지역 특성
## 핵심 차별점 (USP) [한국어로 작성된 지역 특성 분석]
[한국어로 작성된 USP 분석]
## 시즌별 매력 포인트
## 지역 특성 [한국어로 작성된 시즌별 분석]
[한국어로 작성된 지역 특성 분석]
## 마케팅 키워드
## 시즌별 매력 포인트 [한국어로 작성된 마케팅 키워드]
[한국어로 작성된 시즌별 분석]
## JSON Data
## 마케팅 키워드 ```json
[한국어로 작성된 마케팅 키워드] {{
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
## 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]
---
[OUTPUT FORMAT - FAILURE] """.strip()
If you cannot generate analysis due to insufficient information, invalid input, or any other reason: # fmt: on
---
ERROR: [Brief reason for failure in English]
--- class ChatgptService:
""".strip() """ChatGPT API 서비스 클래스
# fmt: on
GPT 5.0 모델을 사용하여 마케팅 가사 분석을 생성합니다.
"""
class ChatgptService:
def __init__( def __init__(
self, self,
customer_name: str, customer_name: str,
region: str, region: str,
detail_region_info: str = "", detail_region_info: str = "",
language: str = "Korean", language: str = "Korean",
): ):
# 최신 모델: GPT-5, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano # 최신 모델: gpt-5-mini
# 이전 세대: GPT-4o, GPT-4o mini, GPT-4 Turbo, GPT-3.5 Turbo self.model = "gpt-5-mini"
self.model = "gpt-4o" self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY) self.customer_name = customer_name
self.customer_name = customer_name self.region = region
self.region = region self.detail_region_info = detail_region_info
self.detail_region_info = detail_region_info self.language = language
self.language = language
def build_lyrics_prompt(self) -> str:
def build_lyrics_prompt(self) -> str: """LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" return LYRICS_PROMPT_TEMPLATE.format(
return LYRICS_PROMPT_TEMPLATE.format( customer_name=self.customer_name,
customer_name=self.customer_name, region=self.region,
region=self.region, detail_region_info=self.detail_region_info,
detail_region_info=self.detail_region_info, language=self.language,
language=self.language, )
)
def build_market_analysis_prompt(self) -> str:
def build_market_analysis_prompt(self) -> str: """MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환""" return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format( customer_name=self.customer_name,
customer_name=self.customer_name, region=self.region,
region=self.region, detail_region_info=self.detail_region_info,
detail_region_info=self.detail_region_info, )
)
async def _call_gpt_api(self, prompt: str) -> str:
async def generate(self, prompt: str | None = None) -> str: """GPT API를 직접 호출합니다 (내부 메서드).
"""GPT에게 프롬프트를 전달하여 결과를 반환"""
if prompt is None: Args:
prompt = self.build_lyrics_prompt() prompt: GPT에 전달할 프롬프트
print("Generated Prompt: ", prompt)
completion = await self.client.chat.completions.create( Returns:
model=self.model, messages=[{"role": "user", "content": prompt}] GPT 응답 문자열
)
message = completion.choices[0].message.content Raises:
return message or "" APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
async def summarize_marketing(self, text: str) -> str: completion = await self.client.chat.completions.create(
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리""" model=self.model, messages=[{"role": "user", "content": prompt}]
prompt = f"""[ROLE] )
마케팅 콘텐츠 요약 전문가 message = completion.choices[0].message.content
return message or ""
[INPUT]
{text} async def generate(
self,
[TASK] prompt: str | None = None,
텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요. ) -> str:
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
[OUTPUT REQUIREMENTS]
- 항목별로 구분하여 정리 (: 타겟 고객, 차별점, 지역 특성 ) Args:
- 500 이내로 요약 prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
- 핵심 정보만 간결하게 포함
- 한국어로 작성 Returns:
GPT 응답 문자열
[OUTPUT FORMAT]
--- Raises:
[항목별로 구분된 500 이내 요약] APIError, APIConnectionError, RateLimitError: OpenAI API 오류
--- """
""" if prompt is None:
completion = await self.client.chat.completions.create( prompt = self.build_lyrics_prompt()
model=self.model, messages=[{"role": "user", "content": prompt}]
) print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
message = completion.choices[0].message.content logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
result = message or ""
# GPT API 호출
# --- 구분자 제거 response = await self._call_gpt_api(prompt)
if result.startswith("---"):
result = result[3:].strip() print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
if result.endswith("---"): logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
result = result[:-3].strip() return response
return result async def summarize_marketing(self, text: str) -> str:
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
async def parse_marketing_analysis(self, raw_response: str) -> dict:
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환 Args:
text: 요약할 마케팅 텍스트
Returns:
dict: {"report": str, "tags": list[str], "facilities": list[str]} Returns:
""" 요약된 텍스트
tags: list[str] = []
facilities: list[str] = [] Raises:
report = raw_response APIError, APIConnectionError, RateLimitError: OpenAI API 오류
"""
# JSON 블록 추출 시도 prompt = f"""[ROLE]
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL) 마케팅 콘텐츠 요약 전문가
if json_match:
try: [INPUT]
json_data = json.loads(json_match.group(1)) {text}
tags = json_data.get("tags", [])
facilities = json_data.get("facilities", []) [TASK]
# JSON 블록을 제외한 리포트 부분 추출 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500 이내로 요약해주세요.
report = raw_response[: json_match.start()].strip()
# --- 구분자 제거 [OUTPUT REQUIREMENTS]
if report.startswith("---"): - 5 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
report = report[3:].strip() - 항목은 줄바꿈으로 구분
if report.endswith("---"): - 500 이내로 요약
report = report[:-3].strip() - 핵심 정보만 간결하게 포함
except json.JSONDecodeError: - 한국어로 작성
pass - 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 제외)
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
# 리포트 내용을 500자로 요약
if report: [OUTPUT FORMAT - 반드시 아래 형식 준수]
report = await self.summarize_marketing(report) ---
타겟 고객
return {"report": report, "tags": tags, "facilities": facilities} [대상 고객층을 자연스러운 문장으로 설명]
핵심 차별점
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
지역 특성
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
시즌별 포인트
[계절별 매력 포인트를 자연스러운 문장으로 설명]
추천 키워드
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
---
"""
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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import cors_settings from config import cors_settings
# sys.path.append( # sys.path.append(
# os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ) # root 경로 추가 # ) # root 경로 추가
class CustomCORSMiddleware: class CustomCORSMiddleware:
def __init__(self, app: FastAPI): def __init__(self, app: FastAPI):
self.app = app self.app = app
def configure_cors(self): def configure_cors(self):
self.app.add_middleware( self.app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=cors_settings.CORS_ALLOW_ORIGINS, allow_origins=cors_settings.CORS_ALLOW_ORIGINS,
allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS, allow_credentials=cors_settings.CORS_ALLOW_CREDENTIALS,
allow_methods=cors_settings.CORS_ALLOW_METHODS, allow_methods=cors_settings.CORS_ALLOW_METHODS,
allow_headers=cors_settings.CORS_ALLOW_HEADERS, allow_headers=cors_settings.CORS_ALLOW_HEADERS,
expose_headers=cors_settings.CORS_EXPOSE_HEADERS, expose_headers=cors_settings.CORS_EXPOSE_HEADERS,
max_age=cors_settings.CORS_MAX_AGE, max_age=cors_settings.CORS_MAX_AGE,
) )

View File

@ -1,437 +1,473 @@
""" """
Creatomate API 클라이언트 모듈 Creatomate API 클라이언트 모듈
API 문서: https://creatomate.com/docs/api API 문서: https://creatomate.com/docs/api
## 사용법 ## 사용법
```python ```python
from app.utils.creatomate import CreatomateService from app.utils.creatomate import CreatomateService
# config에서 자동으로 API 키를 가져옴 # config에서 자동으로 API 키를 가져옴
creatomate = CreatomateService() creatomate = CreatomateService()
# 또는 명시적으로 API 키 전달 # 또는 명시적으로 API 키 전달
creatomate = CreatomateService(api_key="your_api_key") creatomate = CreatomateService(api_key="your_api_key")
# 템플릿 목록 조회 (비동기) # 템플릿 목록 조회 (비동기)
templates = await creatomate.get_all_templates_data() templates = await creatomate.get_all_templates_data()
# 특정 템플릿 조회 (비동기) # 특정 템플릿 조회 (비동기)
template = await creatomate.get_one_template_data(template_id) template = await creatomate.get_one_template_data(template_id)
# 영상 렌더링 요청 (비동기) # 영상 렌더링 요청 (비동기)
response = await creatomate.make_creatomate_call(template_id, modifications) response = await creatomate.make_creatomate_call(template_id, modifications)
``` ```
## 성능 최적화 ## 성능 최적화
- 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 API 호출을 줄입니다. - 템플릿 캐싱: 템플릿 데이터는 메모리에 캐싱되어 반복 조회 API 호출을 줄입니다.
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다. - HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 풀을 재사용합니다.
- 캐시 만료: 기본 5 자동 만료 (CACHE_TTL_SECONDS로 조정 가능) - 캐시 만료: 기본 5 자동 만료 (CACHE_TTL_SECONDS로 조정 가능)
""" """
import copy import copy
import time import logging
from typing import Literal import time
from typing import Literal
import httpx
import httpx
from config import apikey_settings, creatomate_settings
from config import apikey_settings, creatomate_settings
# Orientation 타입 정의 # 로거 설정
OrientationType = Literal["horizontal", "vertical"] logger = logging.getLogger(__name__)
# =============================================================================
# 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴) # Orientation 타입 정의
# ============================================================================= OrientationType = Literal["horizontal", "vertical"]
# 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}} # =============================================================================
_template_cache: dict[str, dict] = {} # 모듈 레벨 캐시 및 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
# 캐시 TTL (초) - 기본 5분
CACHE_TTL_SECONDS = 300 # 템플릿 캐시: {template_id: {"data": dict, "cached_at": float}}
_template_cache: dict[str, dict] = {}
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
_shared_client: httpx.AsyncClient | None = None # 캐시 TTL (초) - 기본 5분
CACHE_TTL_SECONDS = 300
async def get_shared_client() -> httpx.AsyncClient: # 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" _shared_client: httpx.AsyncClient | None = None
global _shared_client
if _shared_client is None or _shared_client.is_closed:
_shared_client = httpx.AsyncClient( async def get_shared_client() -> httpx.AsyncClient:
timeout=httpx.Timeout(60.0, connect=10.0), """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), global _shared_client
) if _shared_client is None or _shared_client.is_closed:
return _shared_client _shared_client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
async def close_shared_client() -> None: )
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요.""" return _shared_client
global _shared_client
if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose() async def close_shared_client() -> None:
_shared_client = None """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
print("[CreatomateService] Shared HTTP client closed") global _shared_client
if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose()
def clear_template_cache() -> None: _shared_client = None
"""템플릿 캐시를 전체 삭제합니다.""" print("[CreatomateService] Shared HTTP client closed")
global _template_cache
_template_cache.clear()
print("[CreatomateService] Template cache cleared") def clear_template_cache() -> None:
"""템플릿 캐시를 전체 삭제합니다."""
global _template_cache
def _is_cache_valid(cached_at: float) -> bool: _template_cache.clear()
"""캐시가 유효한지 확인합니다.""" print("[CreatomateService] Template cache cleared")
return (time.time() - cached_at) < CACHE_TTL_SECONDS
def _is_cache_valid(cached_at: float) -> bool:
class CreatomateService: """캐시가 유효한지 확인합니다."""
"""Creatomate API를 통한 영상 생성 서비스 return (time.time() - cached_at) < CACHE_TTL_SECONDS
모든 HTTP 호출 메서드는 비동기(async) 구현되어 있습니다.
""" class CreatomateService:
"""Creatomate API를 통한 영상 생성 서비스
BASE_URL = "https://api.creatomate.com"
모든 HTTP 호출 메서드는 비동기(async) 구현되어 있습니다.
# 템플릿 설정 (config에서 가져옴) """
TEMPLATE_CONFIG = {
"horizontal": { BASE_URL = "https://api.creatomate.com"
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL, # 템플릿 설정 (config에서 가져옴)
}, TEMPLATE_CONFIG = {
"vertical": { "horizontal": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL, "template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL, "duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
}, },
} "vertical": {
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
def __init__( "duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
self, },
api_key: str | None = None, }
orientation: OrientationType = "vertical",
target_duration: float | None = None, def __init__(
): self,
""" api_key: str | None = None,
Args: orientation: OrientationType = "vertical",
api_key: Creatomate API (Bearer token으로 사용) target_duration: float | None = None,
None일 경우 config에서 자동으로 가져옴 ):
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical") """
target_duration: 목표 영상 길이 () Args:
None일 경우 orientation에 해당하는 기본값 사용 api_key: Creatomate API (Bearer token으로 사용)
""" None일 경우 config에서 자동으로 가져옴
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
self.orientation = orientation target_duration: 목표 영상 길이 ()
None일 경우 orientation에 해당하는 기본값 사용
# orientation에 따른 템플릿 설정 가져오기 """
config = self.TEMPLATE_CONFIG.get( self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
orientation, self.TEMPLATE_CONFIG["vertical"] self.orientation = orientation
)
self.template_id = config["template_id"] # orientation에 따른 템플릿 설정 가져오기
self.target_duration = ( config = self.TEMPLATE_CONFIG.get(
target_duration if target_duration is not None else config["duration"] orientation, self.TEMPLATE_CONFIG["vertical"]
) )
self.template_id = config["template_id"]
self.headers = { self.target_duration = (
"Content-Type": "application/json", target_duration if target_duration is not None else config["duration"]
"Authorization": f"Bearer {self.api_key}", )
}
self.headers = {
async def get_all_templates_data(self) -> dict: "Content-Type": "application/json",
"""모든 템플릿 정보를 조회합니다.""" "Authorization": f"Bearer {self.api_key}",
url = f"{self.BASE_URL}/v1/templates" }
client = await get_shared_client()
response = await client.get(url, headers=self.headers, timeout=30.0) async def _request(
response.raise_for_status() self,
return response.json() method: str,
url: str,
async def get_one_template_data( timeout: float = 30.0,
self, **kwargs,
template_id: str, ) -> httpx.Response:
use_cache: bool = True, """HTTP 요청을 수행합니다.
) -> dict:
"""특정 템플릿 ID로 템플릿 정보를 조회합니다. Args:
method: HTTP 메서드 ("GET", "POST", etc.)
Args: url: 요청 URL
template_id: 조회할 템플릿 ID timeout: 요청 타임아웃 ()
use_cache: 캐시 사용 여부 (기본: True) **kwargs: httpx 요청에 전달할 추가 인자
Returns: Returns:
템플릿 데이터 (deep copy) httpx.Response: 응답 객체
"""
global _template_cache Raises:
httpx.HTTPError: 요청 실패
# 캐시 확인 """
if use_cache and template_id in _template_cache: logger.info(f"[Creatomate] {method} {url}")
cached = _template_cache[template_id] print(f"[Creatomate] {method} {url}")
if _is_cache_valid(cached["cached_at"]):
print(f"[CreatomateService] Cache HIT - {template_id}") client = await get_shared_client()
return copy.deepcopy(cached["data"])
else: if method.upper() == "GET":
# 만료된 캐시 삭제 response = await client.get(
del _template_cache[template_id] url, headers=self.headers, timeout=timeout, **kwargs
print(f"[CreatomateService] Cache EXPIRED - {template_id}") )
elif method.upper() == "POST":
# API 호출 response = await client.post(
url = f"{self.BASE_URL}/v1/templates/{template_id}" url, headers=self.headers, timeout=timeout, **kwargs
client = await get_shared_client() )
response = await client.get(url, headers=self.headers, timeout=30.0) else:
response.raise_for_status() raise ValueError(f"Unsupported HTTP method: {method}")
data = response.json()
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
# 캐시 저장 print(f"[Creatomate] Response - Status: {response.status_code}")
_template_cache[template_id] = { return response
"data": data,
"cached_at": time.time(), async def get_all_templates_data(self) -> dict:
} """모든 템플릿 정보를 조회합니다."""
print(f"[CreatomateService] Cache MISS - {template_id} (cached)") url = f"{self.BASE_URL}/v1/templates"
response = await self._request("GET", url, timeout=30.0)
return copy.deepcopy(data) response.raise_for_status()
return response.json()
# 하위 호환성을 위한 별칭 (deprecated)
async def get_one_template_data_async(self, template_id: str) -> dict: async def get_one_template_data(
"""특정 템플릿 ID로 템플릿 정보를 조회합니다. self,
template_id: str,
Deprecated: get_one_template_data() 사용하세요. use_cache: bool = True,
""" ) -> dict:
return await self.get_one_template_data(template_id) """특정 템플릿 ID로 템플릿 정보를 조회합니다.
def parse_template_component_name(self, template_source: list) -> dict: Args:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" template_id: 조회할 템플릿 ID
use_cache: 캐시 사용 여부 (기본: True)
def recursive_parse_component(element: dict) -> dict:
if "name" in element: Returns:
result_element_name_type = {element["name"]: element["type"]} 템플릿 데이터 (deep copy)
else: """
result_element_name_type = {} global _template_cache
if element["type"] == "composition": # 캐시 확인
minor_component_list = [ if use_cache and template_id in _template_cache:
recursive_parse_component(minor) for minor in element["elements"] cached = _template_cache[template_id]
] if _is_cache_valid(cached["cached_at"]):
# WARNING: Same name component should shroud other component print(f"[CreatomateService] Cache HIT - {template_id}")
for minor_component in minor_component_list: return copy.deepcopy(cached["data"])
result_element_name_type.update(minor_component) else:
# 만료된 캐시 삭제
return result_element_name_type del _template_cache[template_id]
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
result = {}
for result_element_dict in [ # API 호출
recursive_parse_component(component) for component in template_source url = f"{self.BASE_URL}/v1/templates/{template_id}"
]: response = await self._request("GET", url, timeout=30.0)
result.update(result_element_dict) response.raise_for_status()
data = response.json()
return result
# 캐시 저장
async def template_connect_resource_blackbox( _template_cache[template_id] = {
self, "data": data,
template_id: str, "cached_at": time.time(),
image_url_list: list[str], }
lyric: str, print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
music_url: str,
) -> dict: return copy.deepcopy(data)
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
# 하위 호환성을 위한 별칭 (deprecated)
Note: async def get_one_template_data_async(self, template_id: str) -> dict:
- 이미지는 순차적으로 집어넣기 """특정 템플릿 ID로 템플릿 정보를 조회합니다.
- 가사는 개행마다 텍스트 삽입
- Template에 audio-music 항목이 있어야 Deprecated: get_one_template_data() 사용하세요.
""" """
template_data = await self.get_one_template_data(template_id) return await self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name(
template_data["source"]["elements"] def parse_template_component_name(self, template_source: list) -> dict:
) """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
lyric = lyric.replace("\r", "") def recursive_parse_component(element: dict) -> dict:
lyric_splited = lyric.split("\n") if "name" in element:
modifications = {} result_element_name_type = {element["name"]: element["type"]}
else:
for idx, (template_component_name, template_type) in enumerate( result_element_name_type = {}
template_component_data.items()
): if element["type"] == "composition":
match template_type: minor_component_list = [
case "image": recursive_parse_component(minor) for minor in element["elements"]
modifications[template_component_name] = image_url_list[ ]
idx % len(image_url_list) # WARNING: Same name component should shroud other component
] for minor_component in minor_component_list:
case "text": result_element_name_type.update(minor_component)
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited) return result_element_name_type
]
result = {}
modifications["audio-music"] = music_url for result_element_dict in [
recursive_parse_component(component) for component in template_source
return modifications ]:
result.update(result_element_dict)
def elements_connect_resource_blackbox(
self, return result
elements: list,
image_url_list: list[str], async def template_connect_resource_blackbox(
lyric: str, self,
music_url: str, template_id: str,
) -> dict: image_url_list: list[str],
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" lyric: str,
template_component_data = self.parse_template_component_name(elements) music_url: str,
) -> dict:
lyric = lyric.replace("\r", "") """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
lyric_splited = lyric.split("\n")
modifications = {} Note:
- 이미지는 순차적으로 집어넣기
for idx, (template_component_name, template_type) in enumerate( - 가사는 개행마다 텍스트 삽입
template_component_data.items() - Template에 audio-music 항목이 있어야
): """
match template_type: template_data = await self.get_one_template_data(template_id)
case "image": template_component_data = self.parse_template_component_name(
modifications[template_component_name] = image_url_list[ template_data["source"]["elements"]
idx % len(image_url_list) )
]
case "text": lyric = lyric.replace("\r", "")
modifications[template_component_name] = lyric_splited[ lyric_splited = lyric.split("\n")
idx % len(lyric_splited) modifications = {}
]
for idx, (template_component_name, template_type) in enumerate(
modifications["audio-music"] = music_url template_component_data.items()
):
return modifications match template_type:
case "image":
def modify_element(self, elements: list, modification: dict) -> list: modifications[template_component_name] = image_url_list[
"""elements의 source를 modification에 따라 수정합니다.""" idx % len(image_url_list)
]
def recursive_modify(element: dict) -> None: case "text":
if "name" in element: modifications[template_component_name] = lyric_splited[
match element["type"]: idx % len(lyric_splited)
case "image": ]
element["source"] = modification[element["name"]]
case "audio": modifications["audio-music"] = music_url
element["source"] = modification.get(element["name"], "")
case "video": return modifications
element["source"] = modification[element["name"]]
case "text": def elements_connect_resource_blackbox(
element["source"] = modification.get(element["name"], "") self,
case "composition": elements: list,
for minor in element["elements"]: image_url_list: list[str],
recursive_modify(minor) lyric: str,
music_url: str,
for minor in elements: ) -> dict:
recursive_modify(minor) """elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
return elements
lyric = lyric.replace("\r", "")
async def make_creatomate_call( lyric_splited = lyric.split("\n")
self, template_id: str, modifications: dict modifications = {}
) -> dict:
"""Creatomate에 렌더링 요청을 보냅니다. for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
Note: ):
response에 요청 정보가 있으니 폴링 필요 match template_type:
""" case "image":
url = f"{self.BASE_URL}/v2/renders" modifications[template_component_name] = image_url_list[
data = { idx % len(image_url_list)
"template_id": template_id, ]
"modifications": modifications, case "text":
} modifications[template_component_name] = lyric_splited[
client = await get_shared_client() idx % len(lyric_splited)
response = await client.post( ]
url, json=data, headers=self.headers, timeout=60.0
) modifications["audio-music"] = music_url
response.raise_for_status()
return response.json() return modifications
async def make_creatomate_custom_call(self, source: dict) -> dict: def modify_element(self, elements: list, modification: dict) -> list:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. """elements의 source를 modification에 따라 수정합니다."""
Note: def recursive_modify(element: dict) -> None:
response에 요청 정보가 있으니 폴링 필요 if "name" in element:
""" match element["type"]:
url = f"{self.BASE_URL}/v2/renders" case "image":
client = await get_shared_client() element["source"] = modification[element["name"]]
response = await client.post( case "audio":
url, json=source, headers=self.headers, timeout=60.0 element["source"] = modification.get(element["name"], "")
) case "video":
response.raise_for_status() element["source"] = modification[element["name"]]
return response.json() case "text":
element["source"] = modification.get(element["name"], "")
# 하위 호환성을 위한 별칭 (deprecated) case "composition":
async def make_creatomate_custom_call_async(self, source: dict) -> dict: for minor in element["elements"]:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다. recursive_modify(minor)
Deprecated: make_creatomate_custom_call() 사용하세요. for minor in elements:
""" recursive_modify(minor)
return await self.make_creatomate_custom_call(source)
return elements
async def get_render_status(self, render_id: str) -> dict:
"""렌더링 작업의 상태를 조회합니다. async def make_creatomate_call(
self, template_id: str, modifications: dict
Args: ) -> dict:
render_id: Creatomate 렌더 ID """Creatomate에 렌더링 요청을 보냅니다.
Returns: Note:
렌더링 상태 정보 response에 요청 정보가 있으니 폴링 필요
"""
Note: url = f"{self.BASE_URL}/v2/renders"
상태 : data = {
- planned: 예약됨 "template_id": template_id,
- waiting: 대기 "modifications": modifications,
- transcribing: 트랜스크립션 }
- rendering: 렌더링 response = await self._request("POST", url, timeout=60.0, json=data)
- succeeded: 성공 response.raise_for_status()
- failed: 실패 return response.json()
"""
url = f"{self.BASE_URL}/v1/renders/{render_id}" async def make_creatomate_custom_call(self, source: dict) -> dict:
client = await get_shared_client() """템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status() Note:
return response.json() response에 요청 정보가 있으니 폴링 필요
"""
# 하위 호환성을 위한 별칭 (deprecated) url = f"{self.BASE_URL}/v2/renders"
async def get_render_status_async(self, render_id: str) -> dict: response = await self._request("POST", url, timeout=60.0, json=source)
"""렌더링 작업의 상태를 조회합니다. response.raise_for_status()
return response.json()
Deprecated: get_render_status() 사용하세요.
""" # 하위 호환성을 위한 별칭 (deprecated)
return await self.get_render_status(render_id) async def make_creatomate_custom_call_async(self, source: dict) -> dict:
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
def calc_scene_duration(self, template: dict) -> float:
"""템플릿의 전체 장면 duration을 계산합니다.""" Deprecated: make_creatomate_custom_call() 사용하세요.
total_template_duration = 0.0 """
return await self.make_creatomate_custom_call(source)
for elem in template["source"]["elements"]:
try: async def get_render_status(self, render_id: str) -> dict:
if elem["type"] == "audio": """렌더링 작업의 상태를 조회합니다.
continue
total_template_duration += elem["duration"] Args:
if "animations" not in elem: render_id: Creatomate 렌더 ID
continue
for animation in elem["animations"]: Returns:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 렌더링 상태 정보
if animation["transition"]:
total_template_duration -= animation["duration"] Note:
except Exception as e: 상태 :
print(f"[calc_scene_duration] Error processing element: {elem}, {e}") - planned: 예약됨
- waiting: 대기
return total_template_duration - transcribing: 트랜스크립션
- rendering: 렌더링
def extend_template_duration(self, template: dict, target_duration: float) -> dict: - succeeded: 성공
"""템플릿의 duration을 target_duration으로 확장합니다.""" - failed: 실패
template["duration"] = target_duration """
total_template_duration = self.calc_scene_duration(template) url = f"{self.BASE_URL}/v1/renders/{render_id}"
extend_rate = target_duration / total_template_duration response = await self._request("GET", url, timeout=30.0)
new_template = copy.deepcopy(template) response.raise_for_status()
return response.json()
for elem in new_template["source"]["elements"]:
try: # 하위 호환성을 위한 별칭 (deprecated)
if elem["type"] == "audio": async def get_render_status_async(self, render_id: str) -> dict:
continue """렌더링 작업의 상태를 조회합니다.
elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem: Deprecated: get_render_status() 사용하세요.
continue """
for animation in elem["animations"]: return await self.get_render_status(render_id)
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate def calc_scene_duration(self, template: dict) -> float:
except Exception as e: """템플릿의 전체 장면 duration을 계산합니다."""
print( total_template_duration = 0.0
f"[extend_template_duration] Error processing element: {elem}, {e}"
) for elem in template["source"]["elements"]:
try:
return new_template 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 asyncio
import re import json
import logging
import aiohttp import re
from config import crawler_settings import aiohttp
import bs4
class GraphQLException(Exception): from config import crawler_settings
pass
# 로거 설정
logger = logging.getLogger(__name__)
class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
class GraphQLException(Exception):
OVERVIEW_QUERY: str = """ """GraphQL 요청 실패 시 발생하는 예외"""
query getAccommodation($id: String!, $deviceType: String) { pass
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
base {
id class CrawlingTimeoutException(Exception):
name """크롤링 타임아웃 시 발생하는 예외"""
category pass
roadAddress
address
phone class NvMapScraper:
virtualPhone """네이버 지도 GraphQL API 스크래퍼
microReviews
conveniences 네이버 지도에서 숙소/장소 정보를 크롤링합니다.
visitorReviewsTotal """
}
images { images { origin url } } GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
cpImages(source: [ugcImage]) { images { origin url } } REQUEST_TIMEOUT = 120 # 초
}
}""" OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
DEFAULT_HEADERS: dict = { business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
"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", base {
"Referer": "https://map.naver.com/", id
"Origin": "https://map.naver.com", name
"Content-Type": "application/json", category
} roadAddress
address
def __init__(self, url: str, cookies: str | None = None): phone
self.url = url virtualPhone
self.cookies = ( microReviews
cookies if cookies is not None else crawler_settings.NAVER_COOKIES conveniences
) visitorReviewsTotal
self.scrap_type: str | None = None }
self.rawdata: dict | None = None images { images { origin url } }
self.image_link_list: list[str] | None = None cpImages(source: [ugcImage]) { images { origin url } }
self.base_info: dict | None = None }
}"""
def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy() DEFAULT_HEADERS: dict = {
if self.cookies: "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",
headers["Cookie"] = self.cookies "Referer": "https://map.naver.com/",
return headers "Origin": "https://map.naver.com",
"Content-Type": "application/json",
def parse_url(self) -> str: }
place_pattern = r"/place/(\d+)"
match = re.search(place_pattern, self.url) def __init__(self, url: str, cookies: str | None = None):
if not match: self.url = url
raise GraphQLException("Failed to parse place ID from URL") self.cookies = (
return match[1] cookies if cookies is not None else crawler_settings.NAVER_COOKIES
)
async def scrap(self): self.scrap_type: str | None = None
try: self.rawdata: dict | None = None
place_id = self.parse_url() self.image_link_list: list[str] | None = None
data = await self._call_get_accommodation(place_id) self.base_info: dict | None = None
self.rawdata = data self.facility_info: str | None = None
self.image_link_list = [
nv_image["origin"] def _get_request_headers(self) -> dict:
for nv_image in data["data"]["business"]["images"]["images"] headers = self.DEFAULT_HEADERS.copy()
] if self.cookies:
self.base_info = data["data"]["business"]["base"] headers["Cookie"] = self.cookies
self.scrap_type = "GraphQL" return headers
except GraphQLException: async def parse_url(self) -> str:
print("fallback") """URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
self.scrap_type = "Playwright" place_pattern = r"/place/(\d+)"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
# URL에 place가 없는 경우 단축 URL 처리
return if "place" not in self.url:
if "naver.me" in self.url:
async def _call_get_accommodation(self, place_id: str) -> dict: async with aiohttp.ClientSession() as session:
payload = { async with session.get(self.url) as response:
"operationName": "getAccommodation", self.url = str(response.url)
"variables": {"id": place_id, "deviceType": "pc"}, else:
"query": self.OVERVIEW_QUERY, raise GraphQLException("This URL does not contain a place ID")
}
json_payload = json.dumps(payload) match = re.search(place_pattern, self.url)
if not match:
async with aiohttp.ClientSession() as session: raise GraphQLException("Failed to parse place ID from URL")
async with session.post( return match[1]
self.GRAPHQL_URL, data=json_payload, headers=self._get_request_headers()
) as response: async def scrap(self):
if response.status == 200: try:
return await response.json() place_id = await self.parse_url()
else: data = await self._call_get_accommodation(place_id)
print("실패 상태 코드:", response.status) self.rawdata = data
raise GraphQLException( fac_data = await self._get_facility_string(place_id)
f"Request failed with status {response.status}" self.rawdata["facilities"] = fac_data
) self.image_link_list = [
nv_image["origin"]
for nv_image in data["data"]["business"]["images"]["images"]
# if __name__ == "__main__": ]
# import asyncio self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
# 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" self.scrap_type = "GraphQL"
# scraper = NvMapScraper(url)
# asyncio.run(scraper.scrap()) except GraphQLException:
# print(scraper.image_link_list) print("fallback")
# print(len(scraper.image_link_list) if scraper.image_link_list else 0) self.scrap_type = "Playwright"
# print(scraper.base_info) 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 업로드 유틸리티
Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다. Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
파일 경로 또는 바이트 데이터를 직접 업로드할 있습니다. 파일 경로 또는 바이트 데이터를 직접 업로드할 있습니다.
URL 경로 형식: URL 경로 형식:
- 음악: {BASE_URL}/{task_id}/song/{파일명} - 음악: {BASE_URL}/{task_id}/song/{파일명}
- 영상: {BASE_URL}/{task_id}/video/{파일명} - 영상: {BASE_URL}/{task_id}/video/{파일명}
- 이미지: {BASE_URL}/{task_id}/image/{파일명} - 이미지: {BASE_URL}/{task_id}/image/{파일명}
사용 예시: 사용 예시:
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
uploader = AzureBlobUploader(task_id="task-123") uploader = AzureBlobUploader(task_id="task-123")
# 파일 경로로 업로드 # 파일 경로로 업로드
success = await uploader.upload_music(file_path="my_song.mp3") success = await uploader.upload_music(file_path="my_song.mp3")
success = await uploader.upload_video(file_path="my_video.mp4") success = await uploader.upload_video(file_path="my_video.mp4")
success = await uploader.upload_image(file_path="my_image.png") success = await uploader.upload_image(file_path="my_image.png")
# 바이트 데이터로 직접 업로드 (media 저장 없이) # 바이트 데이터로 직접 업로드 (media 저장 없이)
success = await uploader.upload_music_bytes(audio_bytes, "my_song") success = await uploader.upload_music_bytes(audio_bytes, "my_song")
success = await uploader.upload_video_bytes(video_bytes, "my_video") success = await uploader.upload_video_bytes(video_bytes, "my_video")
success = await uploader.upload_image_bytes(image_bytes, "my_image.png") success = await uploader.upload_image_bytes(image_bytes, "my_image.png")
print(uploader.public_url) # 마지막 업로드의 공개 URL print(uploader.public_url) # 마지막 업로드의 공개 URL
성능 최적화: 성능 최적화:
- HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 재사용 - HTTP 클라이언트 재사용: 모듈 레벨의 공유 클라이언트로 커넥션 재사용
- 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다. - 동시 업로드: 공유 클라이언트를 통해 동시 요청 처리가 개선됩니다.
""" """
import asyncio import asyncio
import time import logging
from pathlib import Path import time
from pathlib import Path
import aiofiles
import httpx import aiofiles
import httpx
from config import azure_blob_settings
from config import azure_blob_settings
# ============================================================================= # 로거 설정
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) logger = logging.getLogger(__name__)
# =============================================================================
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용) # 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
_shared_blob_client: httpx.AsyncClient | None = None # =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (커넥션 풀 재사용)
async def get_shared_blob_client() -> httpx.AsyncClient: _shared_blob_client: httpx.AsyncClient | None = None
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_blob_client
if _shared_blob_client is None or _shared_blob_client.is_closed: async def get_shared_blob_client() -> httpx.AsyncClient:
print("[AzureBlobUploader] Creating shared HTTP client...") """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
_shared_blob_client = httpx.AsyncClient( global _shared_blob_client
timeout=httpx.Timeout(180.0, connect=10.0), if _shared_blob_client is None or _shared_blob_client.is_closed:
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), print("[AzureBlobUploader] Creating shared HTTP client...")
) _shared_blob_client = httpx.AsyncClient(
print("[AzureBlobUploader] Shared HTTP client created - " timeout=httpx.Timeout(180.0, connect=10.0),
"max_connections: 20, max_keepalive: 10") limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
return _shared_blob_client )
print("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10")
async def close_shared_blob_client() -> None: return _shared_blob_client
"""공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
global _shared_blob_client
if _shared_blob_client is not None and not _shared_blob_client.is_closed: async def close_shared_blob_client() -> None:
await _shared_blob_client.aclose() """공유 HTTP 클라이언트를 닫습니다. 앱 종료 시 호출하세요."""
_shared_blob_client = None global _shared_blob_client
print("[AzureBlobUploader] Shared HTTP client closed") if _shared_blob_client is not None and not _shared_blob_client.is_closed:
await _shared_blob_client.aclose()
_shared_blob_client = None
class AzureBlobUploader: print("[AzureBlobUploader] Shared HTTP client closed")
"""Azure Blob Storage 업로드 클래스
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다. class AzureBlobUploader:
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN} """Azure Blob Storage 업로드 클래스
카테고리별 경로: Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
- 음악: {task_id}/song/{file_name} URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
- 영상: {task_id}/video/{file_name}
- 이미지: {task_id}/image/{file_name} 카테고리별 경로:
- 음악: {task_id}/song/{file_name}
Attributes: - 영상: {task_id}/video/{file_name}
task_id: 작업 고유 식별자 - 이미지: {task_id}/image/{file_name}
"""
Attributes:
# Content-Type 매핑 task_id: 작업 고유 식별자
IMAGE_CONTENT_TYPES = { """
".jpg": "image/jpeg",
".jpeg": "image/jpeg", # Content-Type 매핑
".png": "image/png", IMAGE_CONTENT_TYPES = {
".gif": "image/gif", ".jpg": "image/jpeg",
".webp": "image/webp", ".jpeg": "image/jpeg",
".bmp": "image/bmp", ".png": "image/png",
} ".gif": "image/gif",
".webp": "image/webp",
def __init__(self, task_id: str): ".bmp": "image/bmp",
"""AzureBlobUploader 초기화 }
Args: def __init__(self, task_id: str):
task_id: 작업 고유 식별자 """AzureBlobUploader 초기화
"""
self._task_id = task_id Args:
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL task_id: 작업 고유 식별자
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN """
self._last_public_url: str = "" self._task_id = task_id
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
@property self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
def task_id(self) -> str: self._last_public_url: str = ""
"""작업 고유 식별자"""
return self._task_id @property
def task_id(self) -> str:
@property """작업 고유 식별자"""
def public_url(self) -> str: return self._task_id
"""마지막 업로드의 공개 URL (SAS 토큰 제외)"""
return self._last_public_url @property
def public_url(self) -> str:
def _build_upload_url(self, category: str, file_name: str) -> str: """마지막 업로드의 공개 URL (SAS 토큰 제외)"""
"""업로드 URL 생성 (SAS 토큰 포함)""" return self._last_public_url
# SAS 토큰 앞뒤의 ?, ', " 제거
sas_token = self._sas_token.strip("?'\"") def _build_upload_url(self, category: str, file_name: str) -> str:
return ( """업로드 URL 생성 (SAS 토큰 포함)"""
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}" # SAS 토큰 앞뒤의 ?, ', " 제거
) sas_token = self._sas_token.strip("?'\"")
return (
def _build_public_url(self, category: str, file_name: str) -> str: f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
"""공개 URL 생성 (SAS 토큰 제외)""" )
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
def _build_public_url(self, category: str, file_name: str) -> str:
async def _upload_bytes( """공개 URL 생성 (SAS 토큰 제외)"""
self, return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
file_content: bytes,
upload_url: str, async def _upload_bytes(
headers: dict, self,
timeout: float, file_content: bytes,
log_prefix: str, upload_url: str,
) -> bool: headers: dict,
"""바이트 데이터를 업로드하는 공통 내부 메서드""" timeout: float,
start_time = time.perf_counter() log_prefix: str,
) -> bool:
try: """바이트 데이터를 업로드하는 공통 내부 메서드
print(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client() Args:
client_time = time.perf_counter() file_content: 업로드할 바이트 데이터
elapsed_ms = (client_time - start_time) * 1000 upload_url: 업로드 URL
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") headers: HTTP 헤더
timeout: 요청 타임아웃 ()
size = len(file_content) log_prefix: 로그 접두사
print(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)") Returns:
bool: 업로드 성공 여부
response = await asyncio.wait_for( """
client.put(upload_url, content=file_content, headers=headers), size = len(file_content)
timeout=timeout, start_time = time.perf_counter()
)
upload_time = time.perf_counter() try:
duration_ms = (upload_time - start_time) * 1000 logger.info(f"[{log_prefix}] Starting upload")
print(f"[{log_prefix}] Getting shared client...")
if response.status_code in [200, 201]:
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, " client = await get_shared_blob_client()
f"Duration: {duration_ms:.1f}ms") client_time = time.perf_counter()
print(f"[{log_prefix}] Public URL: {self._last_public_url}") elapsed_ms = (client_time - start_time) * 1000
return True print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
else:
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, " print(f"[{log_prefix}] Starting upload... "
f"Duration: {duration_ms:.1f}ms") f"(size: {size} bytes, timeout: {timeout}s)")
print(f"[{log_prefix}] Response: {response.text[:500]}")
return False response = await asyncio.wait_for(
client.put(upload_url, content=file_content, headers=headers),
except asyncio.TimeoutError: timeout=timeout,
elapsed = time.perf_counter() - start_time )
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s " upload_time = time.perf_counter()
f"(limit: {timeout}s)") duration_ms = (upload_time - start_time) * 1000
return False
except httpx.ConnectError as e: if response.status_code in [200, 201]:
elapsed = time.perf_counter() - start_time logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - " print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"{type(e).__name__}: {e}") f"Duration: {duration_ms:.1f}ms")
return False print(f"[{log_prefix}] Public URL: {self._last_public_url}")
except httpx.ReadError as e: return True
elapsed = time.perf_counter() - start_time
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - " # 업로드 실패
f"{type(e).__name__}: {e}") logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
return False print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
except Exception as e: f"Duration: {duration_ms:.1f}ms")
elapsed = time.perf_counter() - start_time print(f"[{log_prefix}] Response: {response.text[:500]}")
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - " return False
f"{type(e).__name__}: {e}")
return False except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time
async def _upload_file( logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
self, print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
file_path: str, return False
category: str,
content_type: str, except httpx.ConnectError as e:
timeout: float, elapsed = time.perf_counter() - start_time
log_prefix: str, logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
) -> bool: print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
"""파일을 Azure Blob Storage에 업로드하는 내부 메서드 f"{type(e).__name__}: {e}")
return False
Args:
file_path: 업로드할 파일 경로 except httpx.ReadError as e:
category: 카테고리 (song, video, image) elapsed = time.perf_counter() - start_time
content_type: Content-Type 헤더 logger.error(f"[{log_prefix}] READ_ERROR: {e}")
timeout: 요청 타임아웃 () print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
log_prefix: 로그 접두사 f"{type(e).__name__}: {e}")
return False
Returns:
bool: 업로드 성공 여부 except Exception as e:
""" elapsed = time.perf_counter() - start_time
# 파일 경로에서 파일명 추출 logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
file_name = Path(file_path).name print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
upload_url = self._build_upload_url(category, file_name) return False
self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") async def _upload_file(
self,
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} file_path: str,
category: str,
async with aiofiles.open(file_path, "rb") as file: content_type: str,
file_content = await file.read() timeout: float,
log_prefix: str,
return await self._upload_bytes( ) -> bool:
file_content=file_content, """파일을 Azure Blob Storage에 업로드하는 내부 메서드
upload_url=upload_url,
headers=headers, Args:
timeout=timeout, file_path: 업로드할 파일 경로
log_prefix=log_prefix, category: 카테고리 (song, video, image)
) content_type: Content-Type 헤더
timeout: 요청 타임아웃 ()
async def upload_music(self, file_path: str) -> bool: log_prefix: 로그 접두사
"""음악 파일을 Azure Blob Storage에 업로드합니다.
Returns:
URL 경로: {task_id}/song/{파일명} bool: 업로드 성공 여부
"""
Args: # 파일 경로에서 파일명 추출
file_path: 업로드할 파일 경로 file_name = Path(file_path).name
Returns: upload_url = self._build_upload_url(category, file_name)
bool: 업로드 성공 여부 self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
Example:
uploader = AzureBlobUploader(task_id="task-123") headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
success = await uploader.upload_music(file_path="my_song.mp3")
print(uploader.public_url) async with aiofiles.open(file_path, "rb") as file:
""" file_content = await file.read()
return await self._upload_file(
file_path=file_path, return await self._upload_bytes(
category="song", file_content=file_content,
content_type="audio/mpeg", upload_url=upload_url,
timeout=120.0, headers=headers,
log_prefix="upload_music", timeout=timeout,
) log_prefix=log_prefix,
)
async def upload_music_bytes(
self, file_content: bytes, file_name: str async def upload_music(self, file_path: str) -> bool:
) -> bool: """음악 파일을 Azure Blob Storage에 업로드합니다.
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/song/{파일명}
URL 경로: {task_id}/song/{파일명}
Args:
Args: file_path: 업로드할 파일 경로
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가) Returns:
bool: 업로드 성공 여부
Returns:
bool: 업로드 성공 여부 Example:
uploader = AzureBlobUploader(task_id="task-123")
Example: success = await uploader.upload_music(file_path="my_song.mp3")
uploader = AzureBlobUploader(task_id="task-123") print(uploader.public_url)
success = await uploader.upload_music_bytes(audio_bytes, "my_song") """
print(uploader.public_url) return await self._upload_file(
""" file_path=file_path,
# 확장자가 없으면 .mp3 추가 category="song",
if not Path(file_name).suffix: content_type="audio/mpeg",
file_name = f"{file_name}.mp3" timeout=120.0,
log_prefix="upload_music",
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" async def upload_music_bytes(
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") self, file_content: bytes, file_name: str
) -> bool:
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} """음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
return await self._upload_bytes( URL 경로: {task_id}/song/{파일명}
file_content=file_content,
upload_url=upload_url, Args:
headers=headers, file_content: 업로드할 파일 바이트 데이터
timeout=120.0, file_name: 저장할 파일명 (확장자가 없으면 .mp3 추가)
log_prefix=log_prefix,
) Returns:
bool: 업로드 성공 여부
async def upload_video(self, file_path: str) -> bool:
"""영상 파일을 Azure Blob Storage에 업로드합니다. Example:
uploader = AzureBlobUploader(task_id="task-123")
URL 경로: {task_id}/video/{파일명} success = await uploader.upload_music_bytes(audio_bytes, "my_song")
print(uploader.public_url)
Args: """
file_path: 업로드할 파일 경로 # 확장자가 없으면 .mp3 추가
if not Path(file_name).suffix:
Returns: file_name = f"{file_name}.mp3"
bool: 업로드 성공 여부
upload_url = self._build_upload_url("song", file_name)
Example: self._last_public_url = self._build_public_url("song", file_name)
uploader = AzureBlobUploader(task_id="task-123") log_prefix = "upload_music_bytes"
success = await uploader.upload_video(file_path="my_video.mp4") print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
print(uploader.public_url)
""" headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
return await self._upload_file(
file_path=file_path, return await self._upload_bytes(
category="video", file_content=file_content,
content_type="video/mp4", upload_url=upload_url,
timeout=180.0, headers=headers,
log_prefix="upload_video", timeout=120.0,
) log_prefix=log_prefix,
)
async def upload_video_bytes(
self, file_content: bytes, file_name: str async def upload_video(self, file_path: str) -> bool:
) -> bool: """영상 파일을 Azure Blob Storage에 업로드합니다.
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/video/{파일명}
URL 경로: {task_id}/video/{파일명}
Args:
Args: file_path: 업로드할 파일 경로
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가) Returns:
bool: 업로드 성공 여부
Returns:
bool: 업로드 성공 여부 Example:
uploader = AzureBlobUploader(task_id="task-123")
Example: success = await uploader.upload_video(file_path="my_video.mp4")
uploader = AzureBlobUploader(task_id="task-123") print(uploader.public_url)
success = await uploader.upload_video_bytes(video_bytes, "my_video") """
print(uploader.public_url) return await self._upload_file(
""" file_path=file_path,
# 확장자가 없으면 .mp4 추가 category="video",
if not Path(file_name).suffix: content_type="video/mp4",
file_name = f"{file_name}.mp4" timeout=180.0,
log_prefix="upload_video",
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" async def upload_video_bytes(
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") self, file_content: bytes, file_name: str
) -> bool:
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} """영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
return await self._upload_bytes( URL 경로: {task_id}/video/{파일명}
file_content=file_content,
upload_url=upload_url, Args:
headers=headers, file_content: 업로드할 파일 바이트 데이터
timeout=180.0, file_name: 저장할 파일명 (확장자가 없으면 .mp4 추가)
log_prefix=log_prefix,
) Returns:
bool: 업로드 성공 여부
async def upload_image(self, file_path: str) -> bool:
"""이미지 파일을 Azure Blob Storage에 업로드합니다. Example:
uploader = AzureBlobUploader(task_id="task-123")
URL 경로: {task_id}/image/{파일명} success = await uploader.upload_video_bytes(video_bytes, "my_video")
print(uploader.public_url)
Args: """
file_path: 업로드할 파일 경로 # 확장자가 없으면 .mp4 추가
if not Path(file_name).suffix:
Returns: file_name = f"{file_name}.mp4"
bool: 업로드 성공 여부
upload_url = self._build_upload_url("video", file_name)
Example: self._last_public_url = self._build_public_url("video", file_name)
uploader = AzureBlobUploader(task_id="task-123") log_prefix = "upload_video_bytes"
success = await uploader.upload_image(file_path="my_image.png") print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
print(uploader.public_url)
""" headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
extension = Path(file_path).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") return await self._upload_bytes(
file_content=file_content,
return await self._upload_file( upload_url=upload_url,
file_path=file_path, headers=headers,
category="image", timeout=180.0,
content_type=content_type, log_prefix=log_prefix,
timeout=60.0, )
log_prefix="upload_image",
) async def upload_image(self, file_path: str) -> bool:
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
async def upload_image_bytes(
self, file_content: bytes, file_name: str URL 경로: {task_id}/image/{파일명}
) -> bool:
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다. Args:
file_path: 업로드할 파일 경로
URL 경로: {task_id}/image/{파일명}
Returns:
Args: bool: 업로드 성공 여부
file_content: 업로드할 파일 바이트 데이터
file_name: 저장할 파일명 Example:
uploader = AzureBlobUploader(task_id="task-123")
Returns: success = await uploader.upload_image(file_path="my_image.png")
bool: 업로드 성공 여부 print(uploader.public_url)
"""
Example: extension = Path(file_path).suffix.lower()
uploader = AzureBlobUploader(task_id="task-123") content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
with open("my_image.png", "rb") as f:
content = f.read() return await self._upload_file(
success = await uploader.upload_image_bytes(content, "my_image.png") file_path=file_path,
print(uploader.public_url) category="image",
""" content_type=content_type,
extension = Path(file_name).suffix.lower() timeout=60.0,
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg") log_prefix="upload_image",
)
upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name) async def upload_image_bytes(
log_prefix = "upload_image_bytes" self, file_content: bytes, file_name: str
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") ) -> bool:
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
URL 경로: {task_id}/image/{파일명}
return await self._upload_bytes(
file_content=file_content, Args:
upload_url=upload_url, file_content: 업로드할 파일 바이트 데이터
headers=headers, file_name: 저장할 파일명
timeout=60.0,
log_prefix=log_prefix, Returns:
) bool: 업로드 성공 여부
Example:
# 사용 예시: uploader = AzureBlobUploader(task_id="task-123")
# import asyncio with open("my_image.png", "rb") as f:
# content = f.read()
# async def main(): success = await uploader.upload_image_bytes(content, "my_image.png")
# uploader = AzureBlobUploader(task_id="task-123") print(uploader.public_url)
# """
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3 extension = Path(file_name).suffix.lower()
# await uploader.upload_music("my_song.mp3") content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
# print(uploader.public_url)
# upload_url = self._build_upload_url("image", file_name)
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4 self._last_public_url = self._build_public_url("image", file_name)
# await uploader.upload_video("my_video.mp4") log_prefix = "upload_image_bytes"
# print(uploader.public_url) print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
#
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
# await uploader.upload_image("my_image.png")
# print(uploader.public_url) return await self._upload_bytes(
# file_content=file_content,
# asyncio.run(main()) 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 sqladmin import ModelView
from app.video.models import Video from app.video.models import Video
class VideoAdmin(ModelView, model=Video): class VideoAdmin(ModelView, model=Video):
name = "영상" name = "영상"
name_plural = "영상 목록" name_plural = "영상 목록"
icon = "fa-solid fa-video" icon = "fa-solid fa-video"
category = "영상 관리" category = "영상 관리"
page_size = 20 page_size = 20
column_list = [ column_list = [
"id", "id",
"project_id", "project_id",
"lyric_id", "lyric_id",
"song_id", "song_id",
"task_id", "task_id",
"status", "status",
"created_at", "created_at",
] ]
column_details_list = [ column_details_list = [
"id", "id",
"project_id", "project_id",
"lyric_id", "lyric_id",
"song_id", "song_id",
"task_id", "task_id",
"status", "status",
"result_movie_url", "result_movie_url",
"created_at", "created_at",
] ]
# 폼(생성/수정)에서 제외 # 폼(생성/수정)에서 제외
form_excluded_columns = ["created_at"] form_excluded_columns = ["created_at"]
column_searchable_list = [ column_searchable_list = [
Video.task_id, Video.task_id,
Video.status, Video.status,
] ]
column_default_sort = (Video.created_at, True) # True: DESC (최신순) column_default_sort = (Video.created_at, True) # True: DESC (최신순)
column_sortable_list = [ column_sortable_list = [
Video.id, Video.id,
Video.project_id, Video.project_id,
Video.lyric_id, Video.lyric_id,
Video.song_id, Video.song_id,
Video.status, Video.status,
Video.created_at, Video.created_at,
] ]
column_labels = { column_labels = {
"id": "ID", "id": "ID",
"project_id": "프로젝트 ID", "project_id": "프로젝트 ID",
"lyric_id": "가사 ID", "lyric_id": "가사 ID",
"song_id": "노래 ID", "song_id": "노래 ID",
"task_id": "작업 ID", "task_id": "작업 ID",
"status": "상태", "status": "상태",
"result_movie_url": "영상 URL", "result_movie_url": "영상 URL",
"created_at": "생성일시", "created_at": "생성일시",
} }

View File

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

View File

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

View File

@ -1,156 +1,156 @@
""" """
Video API Schemas Video API Schemas
영상 생성 관련 Pydantic 스키마를 정의합니다. 영상 생성 관련 Pydantic 스키마를 정의합니다.
""" """
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Literal, Optional from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
# ============================================================================= # =============================================================================
# Response Schemas # Response Schemas
# ============================================================================= # =============================================================================
class GenerateVideoResponse(BaseModel): class GenerateVideoResponse(BaseModel):
"""영상 생성 응답 스키마 """영상 생성 응답 스키마
Usage: Usage:
GET /video/generate/{task_id} GET /video/generate/{task_id}
Returns the task IDs for tracking video generation. Returns the task IDs for tracking video generation.
""" """
model_config = ConfigDict( model_config = ConfigDict(
json_schema_extra={ json_schema_extra={
"example": { "example": {
"success": True, "success": True,
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"creatomate_render_id": "render-id-123456", "creatomate_render_id": "render-id-123456",
"message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.", "message": "영상 생성 요청이 접수되었습니다. creatomate_render_id로 상태를 조회하세요.",
"error_message": None, "error_message": None,
} }
} }
) )
success: bool = Field(..., description="요청 성공 여부") success: bool = Field(..., description="요청 성공 여부")
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)") task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID") creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoRenderData(BaseModel): class VideoRenderData(BaseModel):
"""Creatomate 렌더링 결과 데이터""" """Creatomate 렌더링 결과 데이터"""
id: Optional[str] = Field(None, description="렌더 ID") id: Optional[str] = Field(None, description="렌더 ID")
status: Optional[str] = Field(None, description="렌더 상태") status: Optional[str] = Field(None, description="렌더 상태")
url: Optional[str] = Field(None, description="영상 URL") url: Optional[str] = Field(None, description="영상 URL")
snapshot_url: Optional[str] = Field(None, description="스냅샷 URL") snapshot_url: Optional[str] = Field(None, description="스냅샷 URL")
class PollingVideoResponse(BaseModel): class PollingVideoResponse(BaseModel):
"""영상 생성 상태 조회 응답 스키마 """영상 생성 상태 조회 응답 스키마
Usage: Usage:
GET /video/status/{creatomate_render_id} GET /video/status/{creatomate_render_id}
Creatomate API 작업 상태를 조회합니다. Creatomate API 작업 상태를 조회합니다.
Note: Note:
상태 : 상태 :
- planned: 예약됨 - planned: 예약됨
- waiting: 대기 - waiting: 대기
- transcribing: 트랜스크립션 - transcribing: 트랜스크립션
- rendering: 렌더링 - rendering: 렌더링
- succeeded: 성공 - succeeded: 성공
- failed: 실패 - failed: 실패
Example Response (Success): Example Response (Success):
{ {
"success": true, "success": true,
"status": "succeeded", "status": "succeeded",
"message": "영상 생성이 완료되었습니다.", "message": "영상 생성이 완료되었습니다.",
"render_data": { "render_data": {
"id": "render-id", "id": "render-id",
"status": "succeeded", "status": "succeeded",
"url": "https://...", "url": "https://...",
"snapshot_url": "https://..." "snapshot_url": "https://..."
}, },
"raw_response": {...}, "raw_response": {...},
"error_message": null "error_message": null
} }
""" """
success: bool = Field(..., description="조회 성공 여부") success: bool = Field(..., description="조회 성공 여부")
status: Optional[str] = Field( status: Optional[str] = Field(
None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)" None, description="작업 상태 (planned, waiting, rendering, succeeded, failed)"
) )
message: str = Field(..., description="상태 메시지") message: str = Field(..., description="상태 메시지")
render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터") render_data: Optional[VideoRenderData] = Field(None, description="렌더링 결과 데이터")
raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답") raw_response: Optional[Dict[str, Any]] = Field(None, description="Creatomate API 원본 응답")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class DownloadVideoResponse(BaseModel): class DownloadVideoResponse(BaseModel):
"""영상 다운로드 응답 스키마 """영상 다운로드 응답 스키마
Usage: Usage:
GET /video/download/{task_id} GET /video/download/{task_id}
Polls for video completion and returns project info with video URL. Polls for video completion and returns project info with video URL.
Note: Note:
상태 : 상태 :
- processing: 영상 생성 진행 (result_movie_url은 null) - processing: 영상 생성 진행 (result_movie_url은 null)
- completed: 영상 생성 완료 (result_movie_url 포함) - completed: 영상 생성 완료 (result_movie_url 포함)
- failed: 영상 생성 실패 - failed: 영상 생성 실패
- not_found: task_id에 해당하는 Video 없음 - not_found: task_id에 해당하는 Video 없음
- error: 조회 오류 발생 - error: 조회 오류 발생
Example Response (Completed): Example Response (Completed):
{ {
"success": true, "success": true,
"status": "completed", "status": "completed",
"message": "영상 다운로드가 완료되었습니다.", "message": "영상 다운로드가 완료되었습니다.",
"store_name": "스테이 머뭄", "store_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890", "task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00", "created_at": "2025-01-15T12:00:00",
"error_message": null "error_message": null
} }
""" """
success: bool = Field(..., description="다운로드 성공 여부") success: bool = Field(..., description="다운로드 성공 여부")
status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)") status: str = Field(..., description="처리 상태 (processing, completed, failed, not_found, error)")
message: str = Field(..., description="응답 메시지") message: str = Field(..., description="응답 메시지")
store_name: Optional[str] = Field(None, description="업체명") store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
task_id: Optional[str] = Field(None, description="작업 고유 식별자") task_id: Optional[str] = Field(None, description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class VideoListItem(BaseModel): class VideoListItem(BaseModel):
"""영상 목록 아이템 스키마 """영상 목록 아이템 스키마
Usage: Usage:
GET /videos 응답의 개별 영상 정보 GET /videos 응답의 개별 영상 정보
Example: Example:
{ {
"store_name": "스테이 머뭄", "store_name": "스테이 머뭄",
"region": "군산", "region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890", "task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4", "result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"created_at": "2025-01-15T12:00:00" "created_at": "2025-01-15T12:00:00"
} }
""" """
store_name: Optional[str] = Field(None, description="업체명") store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명") region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자") task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL") result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시") 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 Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다. 영상 생성 관련 백그라운드 태스크를 정의합니다.
""" """
from pathlib import Path import logging
import traceback
import aiofiles from pathlib import Path
import httpx
from sqlalchemy import select import aiofiles
import httpx
from app.database.session import BackgroundSessionLocal from sqlalchemy import select
from app.video.models import Video from sqlalchemy.exc import SQLAlchemyError
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.database.session import BackgroundSessionLocal
from app.video.models import Video
async def download_and_upload_video_to_blob( from app.utils.upload_blob_as_request import AzureBlobUploader
task_id: str,
video_url: str, # 로거 설정
store_name: str, logger = logging.getLogger(__name__)
) -> None:
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. # HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
Args:
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL async def _update_video_status(
store_name: 저장할 파일명에 사용할 업체명 task_id: str,
""" status: str,
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") video_url: str | None = None,
temp_file_path: Path | None = None creatomate_render_id: str | None = None,
) -> bool:
try: """Video 테이블의 상태를 업데이트합니다.
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join( Args:
c for c in store_name if c.isalnum() or c in (" ", "_", "-") task_id: 프로젝트 task_id
).strip() status: 변경할 상태 ("processing", "completed", "failed")
safe_store_name = safe_store_name or "video" video_url: 영상 URL
file_name = f"{safe_store_name}.mp4" creatomate_render_id: Creatomate render ID (선택)
# 임시 저장 경로 생성 Returns:
temp_dir = Path("media") / "temp" / task_id bool: 업데이트 성공 여부
temp_dir.mkdir(parents=True, exist_ok=True) """
temp_file_path = temp_dir / file_name try:
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") async with BackgroundSessionLocal() as session:
if creatomate_render_id:
# 영상 파일 다운로드 query_result = await session.execute(
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") select(Video)
async with httpx.AsyncClient() as client: .where(Video.creatomate_render_id == creatomate_render_id)
response = await client.get(video_url, timeout=180.0) .order_by(Video.created_at.desc())
response.raise_for_status() .limit(1)
)
async with aiofiles.open(str(temp_file_path), "wb") as f: else:
await f.write(response.content) query_result = await session.execute(
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") select(Video)
.where(Video.task_id == task_id)
# Azure Blob Storage에 업로드 .order_by(Video.created_at.desc())
uploader = AzureBlobUploader(task_id=task_id) .limit(1)
upload_success = await uploader.upload_video(file_path=str(temp_file_path)) )
if not upload_success: video = query_result.scalar_one_or_none()
raise Exception("Azure Blob Storage 업로드 실패")
if video:
# SAS 토큰이 제외된 public_url 사용 video.status = status
blob_url = uploader.public_url if video_url is not None:
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") video.result_movie_url = video_url
await session.commit()
# Video 테이블 업데이트 (새 세션 사용) logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
async with BackgroundSessionLocal() as session: print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
# 여러 개 있을 경우 가장 최근 것 선택 return True
result = await session.execute( else:
select(Video) logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
.where(Video.task_id == task_id) print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
.order_by(Video.created_at.desc()) return False
.limit(1)
) except SQLAlchemyError as e:
video = result.scalar_one_or_none() 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}")
if video: return False
video.status = "completed" except Exception as e:
video.result_movie_url = blob_url logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
await session.commit() print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, status: completed") return False
else:
print(f"[download_and_upload_video_to_blob] Video NOT FOUND in DB - task_id: {task_id}")
async def _download_video(url: str, task_id: str) -> bytes:
except Exception as e: """URL에서 영상을 다운로드합니다.
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
# 실패 시 Video 테이블 업데이트 Args:
async with BackgroundSessionLocal() as session: url: 다운로드할 URL
result = await session.execute( task_id: 로그용 task_id
select(Video)
.where(Video.task_id == task_id) Returns:
.order_by(Video.created_at.desc()) bytes: 다운로드한 파일 내용
.limit(1)
) Raises:
video = result.scalar_one_or_none() httpx.HTTPError: 다운로드 실패
"""
if video: logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
video.status = "failed" print(f"[VideoDownload] Downloading - task_id: {task_id}")
await session.commit()
print(f"[download_and_upload_video_to_blob] FAILED - task_id: {task_id}, status updated to failed") async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT)
finally: response.raise_for_status()
# 임시 파일 삭제
if temp_file_path and temp_file_path.exists(): logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
try: print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
temp_file_path.unlink() return response.content
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}") async def download_and_upload_video_to_blob(
task_id: str,
# 임시 디렉토리 삭제 시도 video_url: str,
temp_dir = Path("media") / "temp" / task_id store_name: str,
if temp_dir.exists(): ) -> None:
try: """백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
temp_dir.rmdir()
except Exception: Args:
pass # 디렉토리가 비어있지 않으면 무시 task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
async def download_and_upload_video_by_creatomate_render_id( """
creatomate_render_id: str, logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
video_url: str, print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
store_name: str, temp_file_path: Path | None = None
) -> None:
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다. try:
# 파일명에 사용할 수 없는 문자 제거
Args: safe_store_name = "".join(
creatomate_render_id: Creatomate API 렌더 ID c for c in store_name if c.isalnum() or c in (" ", "_", "-")
video_url: 다운로드할 영상 URL ).strip()
store_name: 저장할 파일명에 사용할 업체명 safe_store_name = safe_store_name or "video"
""" file_name = f"{safe_store_name}.mp4"
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 temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
try: temp_file_path = temp_dir / file_name
# creatomate_render_id로 Video 조회하여 task_id 가져오기 logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
async with BackgroundSessionLocal() as session: print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
result = await session.execute(
select(Video) # 영상 파일 다운로드
.where(Video.creatomate_render_id == creatomate_render_id) logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
.order_by(Video.created_at.desc()) print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
.limit(1)
) content = await _download_video(video_url, task_id)
video = result.scalar_one_or_none()
async with aiofiles.open(str(temp_file_path), "wb") as f:
if not video: await f.write(content)
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return 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}")
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}") # Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
# 파일명에 사용할 수 없는 문자 제거 upload_success = await uploader.upload_video(file_path=str(temp_file_path))
safe_store_name = "".join(
c for c in store_name if c.isalnum() or c in (" ", "_", "-") if not upload_success:
).strip() raise Exception("Azure Blob Storage 업로드 실패")
safe_store_name = safe_store_name or "video"
file_name = f"{safe_store_name}.mp4" # 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}")
temp_dir = Path("media") / "temp" / task_id print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name # Video 테이블 업데이트
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") 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}")
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: except httpx.HTTPError as e:
response = await client.get(video_url, timeout=180.0) logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
response.raise_for_status() print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
async with aiofiles.open(str(temp_file_path), "wb") as f: await _update_video_status(task_id, "failed")
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}") except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
# Azure Blob Storage에 업로드 print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
uploader = AzureBlobUploader(task_id=task_id) traceback.print_exc()
upload_success = await uploader.upload_video(file_path=str(temp_file_path)) await _update_video_status(task_id, "failed")
if not upload_success: except Exception as e:
raise Exception("Azure Blob Storage 업로드 실패") 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}")
# SAS 토큰이 제외된 public_url 사용 traceback.print_exc()
blob_url = uploader.public_url await _update_video_status(task_id, "failed")
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
finally:
# Video 테이블 업데이트 (새 세션 사용) # 임시 파일 삭제
async with BackgroundSessionLocal() as session: if temp_file_path and temp_file_path.exists():
result = await session.execute( try:
select(Video) temp_file_path.unlink()
.where(Video.creatomate_render_id == creatomate_render_id) logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
.order_by(Video.created_at.desc()) print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
.limit(1) except Exception as e:
) logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
video = result.scalar_one_or_none() print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
if video: # 임시 디렉토리 삭제 시도
video.status = "completed" temp_dir = Path("media") / "temp" / task_id
video.result_movie_url = blob_url if temp_dir.exists():
await session.commit() try:
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}, status: completed") temp_dir.rmdir()
else: except Exception:
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND in DB - creatomate_render_id: {creatomate_render_id}") pass # 디렉토리가 비어있지 않으면 무시
except Exception as e:
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") async def download_and_upload_video_by_creatomate_render_id(
# 실패 시 Video 테이블 업데이트 creatomate_render_id: str,
if task_id: video_url: str,
async with BackgroundSessionLocal() as session: store_name: str,
result = await session.execute( ) -> None:
select(Video) """creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
.where(Video.creatomate_render_id == creatomate_render_id)
.order_by(Video.created_at.desc()) Args:
.limit(1) creatomate_render_id: Creatomate API 렌더 ID
) video_url: 다운로드할 영상 URL
video = result.scalar_one_or_none() store_name: 저장할 파일명에 사용할 업체명
"""
if video: logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
video.status = "failed" print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
await session.commit() temp_file_path: Path | None = None
print(f"[download_and_upload_video_by_creatomate_render_id] FAILED - creatomate_render_id: {creatomate_render_id}, status updated to failed") task_id: str | None = None
finally: try:
# 임시 파일 삭제 # creatomate_render_id로 Video 조회하여 task_id 가져오기
if temp_file_path and temp_file_path.exists(): async with BackgroundSessionLocal() as session:
try: result = await session.execute(
temp_file_path.unlink() select(Video)
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") .where(Video.creatomate_render_id == creatomate_render_id)
except Exception as e: .order_by(Video.created_at.desc())
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") .limit(1)
)
# 임시 디렉토리 삭제 시도 video = result.scalar_one_or_none()
if task_id:
temp_dir = Path("media") / "temp" / task_id if not video:
if temp_dir.exists(): logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
try: print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
temp_dir.rmdir() return
except Exception:
pass # 디렉토리가 비어있지 않으면 무시 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 pathlib import Path
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent PROJECT_DIR = Path(__file__).resolve().parent
_base_config = SettingsConfigDict( _base_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env", env_file=PROJECT_DIR / ".env",
env_ignore_empty=True, env_ignore_empty=True,
extra="ignore", extra="ignore",
) )
class ProjectSettings(BaseSettings): class ProjectSettings(BaseSettings):
PROJECT_NAME: str = Field(default="CastAD") PROJECT_NAME: str = Field(default="CastAD")
PROJECT_DOMAIN: str = Field(default="localhost:8000") PROJECT_DOMAIN: str = Field(default="localhost:8000")
VERSION: str = Field(default="0.1.0") VERSION: str = Field(default="0.1.0")
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin") ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True) DEBUG: bool = Field(default=True)
model_config = _base_config model_config = _base_config
class APIKeySettings(BaseSettings): class APIKeySettings(BaseSettings):
CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가 CHATGPT_API_KEY: str = Field(default="your-chatgpt-api-key") # 기본값 추가
SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키 SUNO_API_KEY: str = Field(default="your-suno-api-key") # Suno API 키
SUNO_CALLBACK_URL: str = Field( SUNO_CALLBACK_URL: str = Field(
default="https://example.com/api/suno/callback" default="https://example.com/api/suno/callback"
) # Suno 콜백 URL (필수) ) # Suno 콜백 URL (필수)
CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키 CREATOMATE_API_KEY: str = Field(default="your-creatomate-api-key") # Creatomate API 키
model_config = _base_config model_config = _base_config
class CORSSettings(BaseSettings): class CORSSettings(BaseSettings):
# CORS (Cross-Origin Resource Sharing) 설정 # CORS (Cross-Origin Resource Sharing) 설정
# 요청을 허용할 출처(Origin) 목록 # 요청을 허용할 출처(Origin) 목록
# ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장) # ["*"]: 모든 출처 허용 (개발 환경용, 프로덕션에서는 구체적인 도메인 지정 권장)
# 예: ["https://example.com", "https://app.example.com"] # 예: ["https://example.com", "https://app.example.com"]
CORS_ALLOW_ORIGINS: list[str] = ["*"] CORS_ALLOW_ORIGINS: list[str] = ["*"]
# 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부 # 자격 증명(쿠키, Authorization 헤더 등) 포함 요청 허용 여부
# True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능 # True: 클라이언트가 credentials: 'include'로 요청 시 쿠키/인증 정보 전송 가능
# 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장 # 주의: CORS_ALLOW_ORIGINS가 ["*"]일 때는 보안상 False 권장
CORS_ALLOW_CREDENTIALS: bool = True CORS_ALLOW_CREDENTIALS: bool = True
# 허용할 HTTP 메서드 목록 # 허용할 HTTP 메서드 목록
# ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등) # ["*"]: 모든 메서드 허용 (GET, POST, PUT, DELETE, PATCH, OPTIONS 등)
# 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"] # 구체적 지정 예: ["GET", "POST", "PUT", "DELETE"]
CORS_ALLOW_METHODS: list[str] = ["*"] CORS_ALLOW_METHODS: list[str] = ["*"]
# 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록 # 클라이언트가 요청 시 사용할 수 있는 HTTP 헤더 목록
# ["*"]: 모든 헤더 허용 # ["*"]: 모든 헤더 허용
# 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"] # 구체적 지정 예: ["Content-Type", "Authorization", "X-Custom-Header"]
CORS_ALLOW_HEADERS: list[str] = ["*"] CORS_ALLOW_HEADERS: list[str] = ["*"]
# 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록 # 브라우저의 JavaScript에서 접근 가능한 응답 헤더 목록
# []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type, # []: 기본 안전 헤더(Cache-Control, Content-Language, Content-Type,
# Expires, Last-Modified, Pragma)만 접근 가능 # Expires, Last-Modified, Pragma)만 접근 가능
# 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"] # 추가 노출 필요 시: ["X-Total-Count", "X-Request-Id", "X-Custom-Header"]
CORS_EXPOSE_HEADERS: list[str] = [] CORS_EXPOSE_HEADERS: list[str] = []
# Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초) # Preflight 요청(OPTIONS) 결과를 캐시하는 시간(초)
# 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략) # 600: 10분간 캐시 (이 시간 동안 동일 요청에 대해 preflight 생략)
# 0으로 설정 시 매번 preflight 요청 발생 # 0으로 설정 시 매번 preflight 요청 발생
CORS_MAX_AGE: int = 600 CORS_MAX_AGE: int = 600
model_config = _base_config model_config = _base_config
class DatabaseSettings(BaseSettings): class DatabaseSettings(BaseSettings):
# MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB) # MySQL 연결 설정 (기본값: 테스트 계정 및 poc DB)
MYSQL_HOST: str = Field(default="localhost") MYSQL_HOST: str = Field(default="localhost")
MYSQL_PORT: int = Field(default=3306) MYSQL_PORT: int = Field(default=3306)
MYSQL_USER: str = Field(default="test") MYSQL_USER: str = Field(default="test")
MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드 MYSQL_PASSWORD: str = Field(default="") # 환경변수에서 로드
MYSQL_DB: str = Field(default="poc") MYSQL_DB: str = Field(default="poc")
# Redis 설정 # Redis 설정
REDIS_HOST: str = "localhost" REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379 REDIS_PORT: int = 6379
model_config = _base_config model_config = _base_config
@property @property
def MYSQL_URL(self) -> str: def MYSQL_URL(self) -> str:
"""비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)""" """비동기 MySQL URL 생성 (asyncmy 드라이버 사용, SQLAlchemy 통합 최적화)"""
return f"mysql+asyncmy://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DB}" 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: def REDIS_URL(self, db: int = 0) -> str:
"""Redis URL 생성 (db 인수로 기본값 지원)""" """Redis URL 생성 (db 인수로 기본값 지원)"""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}" return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
class SecuritySettings(BaseSettings): class SecuritySettings(BaseSettings):
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전) JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전) JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
model_config = _base_config model_config = _base_config
class NotificationSettings(BaseSettings): class NotificationSettings(BaseSettings):
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가 MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가 MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
MAIL_FROM: str = "your-email@example.com" # 기본값 추가 MAIL_FROM: str = "your-email@example.com" # 기본값 추가
MAIL_PORT: int = 587 # 기본값 추가 MAIL_PORT: int = 587 # 기본값 추가
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가 MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가 MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
MAIL_STARTTLS: bool = True MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True VALIDATE_CERTS: bool = True
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가 TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가 TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가 TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
model_config = _base_config model_config = _base_config
class CrawlerSettings(BaseSettings): class CrawlerSettings(BaseSettings):
NAVER_COOKIES: str = Field(default="") NAVER_COOKIES: str = Field(default="")
model_config = _base_config model_config = _base_config
class AzureBlobSettings(BaseSettings): class AzureBlobSettings(BaseSettings):
"""Azure Blob Storage 설정""" """Azure Blob Storage 설정"""
AZURE_BLOB_SAS_TOKEN: str = Field( AZURE_BLOB_SAS_TOKEN: str = Field(
default="", default="",
description="Azure Blob Storage SAS 토큰", description="Azure Blob Storage SAS 토큰",
) )
AZURE_BLOB_BASE_URL: str = Field( AZURE_BLOB_BASE_URL: str = Field(
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original", default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
description="Azure Blob Storage 기본 URL", description="Azure Blob Storage 기본 URL",
) )
model_config = _base_config model_config = _base_config
class CreatomateSettings(BaseSettings): class CreatomateSettings(BaseSettings):
"""Creatomate 템플릿 설정""" """Creatomate 템플릿 설정"""
# 세로형 템플릿 (기본값) # 세로형 템플릿 (기본값)
TEMPLATE_ID_VERTICAL: str = Field( TEMPLATE_ID_VERTICAL: str = Field(
default="e8c7b43f-de4b-4ba3-b8eb-5df688569193", default="e8c7b43f-de4b-4ba3-b8eb-5df688569193",
description="Creatomate 세로형 템플릿 ID", description="Creatomate 세로형 템플릿 ID",
) )
TEMPLATE_DURATION_VERTICAL: float = Field( TEMPLATE_DURATION_VERTICAL: float = Field(
default=90.0, default=90.0,
description="세로형 템플릿 기본 duration (초)", description="세로형 템플릿 기본 duration (초)",
) )
# 가로형 템플릿 # 가로형 템플릿
TEMPLATE_ID_HORIZONTAL: str = Field( TEMPLATE_ID_HORIZONTAL: str = Field(
default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7", default="0f092a6a-f526-4ef0-9181-d4ad4426b9e7",
description="Creatomate 가로형 템플릿 ID", description="Creatomate 가로형 템플릿 ID",
) )
TEMPLATE_DURATION_HORIZONTAL: float = Field( TEMPLATE_DURATION_HORIZONTAL: float = Field(
default=30.0, default=30.0,
description="가로형 템플릿 기본 duration (초)", description="가로형 템플릿 기본 duration (초)",
) )
model_config = _base_config model_config = _base_config
prj_settings = ProjectSettings() prj_settings = ProjectSettings()
apikey_settings = APIKeySettings() apikey_settings = APIKeySettings()
db_settings = DatabaseSettings() db_settings = DatabaseSettings()
security_settings = SecuritySettings() security_settings = SecuritySettings()
notification_settings = NotificationSettings() notification_settings = NotificationSettings()
cors_settings = CORSSettings() cors_settings = CORSSettings()
crawler_settings = CrawlerSettings() crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings() azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings() 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.1 N+1 쿼리 문제 (video.py:596-612) ### 1.1 N+1 쿼리 문제 (video.py:596-612)
```python ```python
# get_videos() 엔드포인트에서 # get_videos() 엔드포인트에서
for video in videos: for video in videos:
# 매 video마다 별도의 DB 쿼리 실행 - N+1 문제! # 매 video마다 별도의 DB 쿼리 실행 - N+1 문제!
project_result = await session.execute( project_result = await session.execute(
select(Project).where(Project.id == video.project_id) select(Project).where(Project.id == video.project_id)
) )
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
``` ```
**문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다. **문제점**: 비디오 목록 조회 시 각 비디오마다 별도의 Project 쿼리가 발생합니다. 10개 비디오 조회 시 11번의 DB 쿼리가 실행됩니다.
**개선 방안**: **개선 방안**:
```python ```python
# selectinload를 사용한 eager loading # selectinload를 사용한 eager loading
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
query = ( query = (
select(Video) select(Video)
.options(selectinload(Video.project)) # relationship 필요 .options(selectinload(Video.project)) # relationship 필요
.where(Video.id.in_(select(subquery.c.max_id))) .where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc()) .order_by(Video.created_at.desc())
.offset(offset) .offset(offset)
.limit(pagination.page_size) .limit(pagination.page_size)
) )
# 또는 한 번에 project_ids 수집 후 일괄 조회 # 또는 한 번에 project_ids 수집 후 일괄 조회
project_ids = [v.project_id for v in videos] project_ids = [v.project_id for v in videos]
projects_result = await session.execute( projects_result = await session.execute(
select(Project).where(Project.id.in_(project_ids)) select(Project).where(Project.id.in_(project_ids))
) )
projects_map = {p.id: p for p in projects_result.scalars().all()} projects_map = {p.id: p for p in projects_result.scalars().all()}
``` ```
--- ---
### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276) ### 1.2 가사 생성 API의 블로킹 문제 (lyric.py:274-276)
```python ```python
# ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨 # ChatGPT API 호출이 완료될 때까지 HTTP 응답이 블로킹됨
print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}") print(f"[generate_lyric] ChatGPT generation started - task_id: {task_id}")
result = await service.generate(prompt=prompt) # 수 초~수십 초 소요 result = await service.generate(prompt=prompt) # 수 초~수십 초 소요
print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}") print(f"[generate_lyric] ChatGPT generation completed - task_id: {task_id}")
``` ```
**문제점**: **문제점**:
- ChatGPT API 응답이 5-30초 이상 걸릴 수 있음 - ChatGPT API 응답이 5-30초 이상 걸릴 수 있음
- 이 시간 동안 클라이언트 연결이 유지되어야 함 - 이 시간 동안 클라이언트 연결이 유지되어야 함
- 다수 동시 요청 시 worker 스레드 고갈 가능성 - 다수 동시 요청 시 worker 스레드 고갈 가능성
**개선 방안 (BackgroundTask 패턴)**: **개선 방안 (BackgroundTask 패턴)**:
```python ```python
@router.post("/generate") @router.post("/generate")
async def generate_lyric( async def generate_lyric(
request_body: GenerateLyricRequest, request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse: ) -> GenerateLyricResponse:
# DB에 processing 상태로 저장 # DB에 processing 상태로 저장
lyric = Lyric(status="processing", ...) lyric = Lyric(status="processing", ...)
session.add(lyric) session.add(lyric)
await session.commit() await session.commit()
# 백그라운드에서 ChatGPT 호출 # 백그라운드에서 ChatGPT 호출
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
task_id=task_id, task_id=task_id,
prompt=prompt, prompt=prompt,
) )
# 즉시 응답 반환 # 즉시 응답 반환
return GenerateLyricResponse( return GenerateLyricResponse(
success=True, success=True,
task_id=task_id, task_id=task_id,
message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.", message="가사 생성이 시작되었습니다. /status/{task_id}로 상태를 확인하세요.",
) )
``` ```
--- ---
### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py) ### 1.3 Creatomate 서비스의 동기/비동기 메서드 혼재 (creatomate.py)
**문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다. **문제점**: 동기 메서드가 여전히 존재하여 실수로 async 컨텍스트에서 호출될 수 있습니다.
| 동기 메서드 | 비동기 메서드 | | 동기 메서드 | 비동기 메서드 |
|------------|--------------| |------------|--------------|
| `get_all_templates_data()` | 없음 | | `get_all_templates_data()` | 없음 |
| `get_one_template_data()` | `get_one_template_data_async()` | | `get_one_template_data()` | `get_one_template_data_async()` |
| `make_creatomate_call()` | 없음 | | `make_creatomate_call()` | 없음 |
| `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` | | `make_creatomate_custom_call()` | `make_creatomate_custom_call_async()` |
| `get_render_status()` | `get_render_status_async()` | | `get_render_status()` | `get_render_status_async()` |
**개선 방안**: **개선 방안**:
```python ```python
# 모든 HTTP 호출 메서드를 async로 통일 # 모든 HTTP 호출 메서드를 async로 통일
async def get_all_templates_data(self) -> dict: async def get_all_templates_data(self) -> dict:
url = f"{self.BASE_URL}/v1/templates" url = f"{self.BASE_URL}/v1/templates"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0) response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
# 동기 버전 제거 또는 deprecated 표시 # 동기 버전 제거 또는 deprecated 표시
``` ```
--- ---
## 2. 심각도 중간 - 개선 권장 ## 2. 심각도 중간 - 개선 권장
### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127) ### 2.1 백그라운드 태스크에서 매번 엔진 생성 (session.py:82-127)
```python ```python
@asynccontextmanager @asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
# 매 호출마다 새 엔진 생성 - 오버헤드 발생 # 매 호출마다 새 엔진 생성 - 오버헤드 발생
worker_engine = create_async_engine( worker_engine = create_async_engine(
url=db_settings.MYSQL_URL, url=db_settings.MYSQL_URL,
poolclass=NullPool, poolclass=NullPool,
... ...
) )
``` ```
**문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다. **문제점**: 백그라운드 태스크가 빈번하게 호출되면 엔진 생성/소멸 오버헤드가 증가합니다.
**개선 방안**: **개선 방안**:
```python ```python
# 모듈 레벨에서 워커 전용 엔진 생성 # 모듈 레벨에서 워커 전용 엔진 생성
_worker_engine = create_async_engine( _worker_engine = create_async_engine(
url=db_settings.MYSQL_URL, url=db_settings.MYSQL_URL,
poolclass=NullPool, poolclass=NullPool,
) )
_WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...) _WorkerSessionLocal = async_sessionmaker(bind=_worker_engine, ...)
@asynccontextmanager @asynccontextmanager
async def get_worker_session() -> AsyncGenerator[AsyncSession, None]: async def get_worker_session() -> AsyncGenerator[AsyncSession, None]:
async with _WorkerSessionLocal() as session: async with _WorkerSessionLocal() as session:
try: try:
yield session yield session
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
raise e raise e
``` ```
--- ---
### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54) ### 2.2 대용량 파일 다운로드 시 메모리 사용 (video_task.py:49-54)
```python ```python
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(video_url, timeout=180.0) response = await client.get(video_url, timeout=180.0)
response.raise_for_status() response.raise_for_status()
# 전체 파일을 메모리에 로드 - 대용량 영상 시 문제 # 전체 파일을 메모리에 로드 - 대용량 영상 시 문제
async with aiofiles.open(str(temp_file_path), "wb") as f: async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(response.content) await f.write(response.content)
``` ```
**문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다. **문제점**: 수백 MB 크기의 영상 파일을 한 번에 메모리에 로드합니다.
**개선 방안 - 스트리밍 다운로드**: **개선 방안 - 스트리밍 다운로드**:
```python ```python
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
async with client.stream("GET", video_url, timeout=180.0) as response: async with client.stream("GET", video_url, timeout=180.0) as response:
response.raise_for_status() response.raise_for_status()
async with aiofiles.open(str(temp_file_path), "wb") as f: async with aiofiles.open(str(temp_file_path), "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192): async for chunk in response.aiter_bytes(chunk_size=8192):
await f.write(chunk) await f.write(chunk)
``` ```
--- ---
### 2.3 httpx.AsyncClient 반복 생성 ### 2.3 httpx.AsyncClient 반복 생성
여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다. 여러 곳에서 `async with httpx.AsyncClient() as client:`를 사용하여 매번 새 클라이언트를 생성합니다.
**개선 방안 - 재사용 가능한 클라이언트**: **개선 방안 - 재사용 가능한 클라이언트**:
```python ```python
# app/utils/http_client.py # app/utils/http_client.py
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import httpx import httpx
_client: httpx.AsyncClient | None = None _client: httpx.AsyncClient | None = None
async def get_http_client() -> httpx.AsyncClient: async def get_http_client() -> httpx.AsyncClient:
global _client global _client
if _client is None: if _client is None:
_client = httpx.AsyncClient(timeout=30.0) _client = httpx.AsyncClient(timeout=30.0)
return _client return _client
async def close_http_client(): async def close_http_client():
global _client global _client
if _client: if _client:
await _client.aclose() await _client.aclose()
_client = None _client = None
``` ```
--- ---
## 3. 심각도 낮음 - 선택적 개선 ## 3. 심각도 낮음 - 선택적 개선
### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191) ### 3.1 generate_video 엔드포인트의 다중 DB 조회 (video.py:109-191)
```python ```python
# 4개의 개별 쿼리가 순차적으로 실행됨 # 4개의 개별 쿼리가 순차적으로 실행됨
project_result = await session.execute(select(Project).where(...)) project_result = await session.execute(select(Project).where(...))
lyric_result = await session.execute(select(Lyric).where(...)) lyric_result = await session.execute(select(Lyric).where(...))
song_result = await session.execute(select(Song).where(...)) song_result = await session.execute(select(Song).where(...))
image_result = await session.execute(select(Image).where(...)) image_result = await session.execute(select(Image).where(...))
``` ```
**개선 방안 - 병렬 쿼리 실행**: **개선 방안 - 병렬 쿼리 실행**:
```python ```python
import asyncio import asyncio
project_task = session.execute(select(Project).where(Project.task_id == task_id)) project_task = session.execute(select(Project).where(Project.task_id == task_id))
lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id)) lyric_task = session.execute(select(Lyric).where(Lyric.task_id == task_id))
song_task = session.execute( song_task = session.execute(
select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1) select(Song).where(Song.task_id == task_id).order_by(Song.created_at.desc()).limit(1)
) )
image_task = session.execute( image_task = session.execute(
select(Image).where(Image.task_id == task_id).order_by(Image.img_order.asc()) 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_result, lyric_result, song_result, image_result = await asyncio.gather(
project_task, lyric_task, song_task, image_task project_task, lyric_task, song_task, image_task
) )
``` ```
--- ---
### 3.2 템플릿 조회 캐싱 미적용 ### 3.2 템플릿 조회 캐싱 미적용
`get_one_template_data_async()`가 매번 Creatomate API를 호출합니다. `get_one_template_data_async()`가 매번 Creatomate API를 호출합니다.
**개선 방안 - 간단한 메모리 캐싱**: **개선 방안 - 간단한 메모리 캐싱**:
```python ```python
from functools import lru_cache from functools import lru_cache
from cachetools import TTLCache from cachetools import TTLCache
_template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시 _template_cache = TTLCache(maxsize=100, ttl=3600) # 1시간 캐시
async def get_one_template_data_async(self, template_id: str) -> dict: async def get_one_template_data_async(self, template_id: str) -> dict:
if template_id in _template_cache: if template_id in _template_cache:
return _template_cache[template_id] return _template_cache[template_id]
url = f"{self.BASE_URL}/v1/templates/{template_id}" url = f"{self.BASE_URL}/v1/templates/{template_id}"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url, headers=self.headers, timeout=30.0) response = await client.get(url, headers=self.headers, timeout=30.0)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
_template_cache[template_id] = data _template_cache[template_id] = data
return data return data
``` ```
--- ---
## 4. 긍정적인 부분 (잘 구현된 패턴) ## 4. 긍정적인 부분 (잘 구현된 패턴)
| 항목 | 상태 | 설명 | | 항목 | 상태 | 설명 |
|------|------|------| |------|------|------|
| SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 | | SQLAlchemy AsyncSession | O | `asyncmy` 드라이버와 `AsyncSessionLocal` 사용 |
| 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 | | 파일 I/O | O | `aiofiles` 사용으로 비동기 파일 처리 |
| HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 | | HTTP 클라이언트 | O | `httpx.AsyncClient` 사용 |
| OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 | | OpenAI API | O | `AsyncOpenAI` 클라이언트 사용 |
| 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 | | 백그라운드 태스크 | O | FastAPI `BackgroundTasks` 적절히 사용 |
| 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 | | 세션 관리 | O | 메인/워커 세션 분리로 이벤트 루프 충돌 방지 |
| 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 | | 연결 풀 설정 | O | `pool_size`, `pool_recycle`, `pool_pre_ping` 적절히 설정 |
--- ---
## 5. 우선순위별 개선 권장 사항 ## 5. 우선순위별 개선 권장 사항
| 우선순위 | 항목 | 예상 효과 | | 우선순위 | 항목 | 예상 효과 |
|----------|------|----------| |----------|------|----------|
| **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 | | **1** | N+1 쿼리 문제 해결 | DB 부하 감소, 응답 속도 개선 |
| **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 | | **2** | 가사 생성 백그라운드 처리 | 동시 요청 처리 능력 향상 |
| **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 | | **3** | Creatomate 동기 메서드 제거 | 실수로 인한 블로킹 방지 |
| **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 | | **4** | 대용량 파일 스트리밍 다운로드 | 메모리 사용량 감소 |
| **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 | | **5** | 워커 세션 엔진 재사용 | 오버헤드 감소 |
--- ---
## 분석 일자 ## 분석 일자
2024-12-29 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 테이블 -- input_history 테이블
CREATE TABLE input_history ( CREATE TABLE input_history (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
customer_name VARCHAR(255) NOT NULL, customer_name VARCHAR(255) NOT NULL,
region VARCHAR(100) NOT NULL, region VARCHAR(100) NOT NULL,
task_id CHAR(36) NOT NULL, task_id CHAR(36) NOT NULL,
detail_region_info TEXT, detail_region_info TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- upload_img_url 테이블 -- upload_img_url 테이블
CREATE TABLE upload_img_url ( CREATE TABLE upload_img_url (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
task_id CHAR(36) NOT NULL, task_id CHAR(36) NOT NULL,
img_uid INT NOT NULL, img_uid INT NOT NULL,
img_url VARCHAR(2048) NOT NULL, img_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE FOREIGN KEY (task_id) REFERENCES input_history(task_id) ON DELETE CASCADE
); );
-- lyrics 테이블 -- lyrics 테이블
CREATE TABLE lyrics ( CREATE TABLE lyrics (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL, input_history_id INT NOT NULL,
task_id CHAR(36) NOT NULL, task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL,
lyrics_prompt TEXT NOT NULL, lyrics_prompt TEXT NOT NULL,
lyrics_result LONGTEXT NOT NULL, lyrics_result LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE
); );
-- song 테이블 -- song 테이블
CREATE TABLE song ( CREATE TABLE song (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL, input_history_id INT NOT NULL,
lyrics_id INT NOT NULL, lyrics_id INT NOT NULL,
task_id CHAR(36) NOT NULL, task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL,
song_prompt TEXT NOT NULL, song_prompt TEXT NOT NULL,
song_result_url_1 VARCHAR(2048), song_result_url_1 VARCHAR(2048),
song_result_url_2 VARCHAR(2048), song_result_url_2 VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE FOREIGN KEY (lyrics_id) REFERENCES lyrics(id) ON DELETE CASCADE
); );
-- creatomate_result_url 테이블 -- creatomate_result_url 테이블
CREATE TABLE creatomate_result_url ( CREATE TABLE creatomate_result_url (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
input_history_id INT NOT NULL, input_history_id INT NOT NULL,
song_id INT NOT NULL, song_id INT NOT NULL,
task_id CHAR(36) NOT NULL, task_id CHAR(36) NOT NULL,
status VARCHAR(50) NOT NULL, status VARCHAR(50) NOT NULL,
result_movie_url VARCHAR(2048), result_movie_url VARCHAR(2048),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE, FOREIGN KEY (input_history_id) REFERENCES input_history(id) ON DELETE CASCADE,
FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE FOREIGN KEY (song_id) REFERENCES song(id) ON DELETE CASCADE
); );
-- ===== 인덱스 추가 (쿼리 성능 최적화) ===== -- ===== 인덱스 추가 (쿼리 성능 최적화) =====
-- input_history -- input_history
CREATE INDEX idx_input_history_task_id ON input_history(task_id); CREATE INDEX idx_input_history_task_id ON input_history(task_id);
-- upload_img_url (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 ON upload_img_url(task_id);
CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid); CREATE INDEX idx_upload_img_url_task_id_img_uid ON upload_img_url(task_id, img_uid);
-- lyrics (input_history_id + task_id 인덱스) -- lyrics (input_history_id + task_id 인덱스)
CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id); CREATE INDEX idx_lyrics_input_history_id ON lyrics(input_history_id);
CREATE INDEX idx_lyrics_task_id ON lyrics(task_id); CREATE INDEX idx_lyrics_task_id ON lyrics(task_id);
-- song (input_history_id + lyrics_id + 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_input_history_id ON song(input_history_id);
CREATE INDEX idx_song_lyrics_id ON song(lyrics_id); CREATE INDEX idx_song_lyrics_id ON song(lyrics_id);
CREATE INDEX idx_song_task_id ON song(task_id); CREATE INDEX idx_song_task_id ON song(task_id);
-- creatomate_result_url (input_history_id + song_id + 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_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_song_id ON creatomate_result_url(song_id);
CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id); CREATE INDEX idx_creatomate_task_id ON creatomate_result_url(task_id);

View File

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

View File

@ -1,382 +1,382 @@
# Pydantic ConfigDict 사용 매뉴얼 # Pydantic ConfigDict 사용 매뉴얼
## 개요 ## 개요
Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다. Pydantic v2에서 `ConfigDict`는 모델의 유효성 검사, 직렬화, JSON 스키마 생성 등의 동작을 제어하는 설정을 정의하는 TypedDict입니다.
> Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다. > Pydantic v1의 `class Config`는 더 이상 권장되지 않으며, `ConfigDict`를 사용해야 합니다.
## 기본 사용법 ## 기본 사용법
```python ```python
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class MyModel(BaseModel): class MyModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
str_strip_whitespace=True, str_strip_whitespace=True,
strict=True strict=True
) )
name: str name: str
age: int age: int
``` ```
## 설정 옵션 전체 목록 ## 설정 옵션 전체 목록
### 문자열 처리 ### 문자열 처리
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 | | `str_to_lower` | `bool` | `False` | 문자열을 소문자로 변환 |
| `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 | | `str_to_upper` | `bool` | `False` | 문자열을 대문자로 변환 |
| `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 | | `str_strip_whitespace` | `bool` | `False` | 문자열 앞뒤 공백 제거 |
| `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 | | `str_min_length` | `int \| None` | `None` | 문자열 최소 길이 |
| `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 | | `str_max_length` | `int \| None` | `None` | 문자열 최대 길이 |
**예시:** **예시:**
```python ```python
class UserInput(BaseModel): class UserInput(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
str_strip_whitespace=True, str_strip_whitespace=True,
str_to_lower=True, str_to_lower=True,
str_min_length=1, str_min_length=1,
str_max_length=100 str_max_length=100
) )
username: str username: str
user = UserInput(username=" HELLO ") user = UserInput(username=" HELLO ")
print(user.username) # "hello" print(user.username) # "hello"
``` ```
### 유효성 검사 ### 유효성 검사
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) | | `strict` | `bool` | `False` | 엄격한 타입 검사 활성화 (타입 강제 변환 비활성화) |
| `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 | | `validate_assignment` | `bool` | `False` | 속성 할당 시 유효성 검사 수행 |
| `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 | | `validate_default` | `bool` | `False` | 기본값도 유효성 검사 수행 |
| `validate_return` | `bool` | `False` | 반환값 유효성 검사 | | `validate_return` | `bool` | `False` | 반환값 유효성 검사 |
| `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 | | `revalidate_instances` | `Literal['always', 'never', 'subclass-instances']` | `'never'` | 모델 인스턴스 재검증 시점 |
| `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 | | `arbitrary_types_allowed` | `bool` | `False` | Pydantic이 지원하지 않는 타입 허용 |
**예시 - strict 모드:** **예시 - strict 모드:**
```python ```python
class StrictModel(BaseModel): class StrictModel(BaseModel):
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
count: int count: int
# strict=False (기본값): "123" -> 123 자동 변환 # strict=False (기본값): "123" -> 123 자동 변환
# strict=True: "123" 입력 시 ValidationError 발생 # strict=True: "123" 입력 시 ValidationError 발생
``` ```
**예시 - validate_assignment:** **예시 - validate_assignment:**
```python ```python
class User(BaseModel): class User(BaseModel):
model_config = ConfigDict(validate_assignment=True) model_config = ConfigDict(validate_assignment=True)
age: int age: int
user = User(age=25) user = User(age=25)
user.age = "invalid" # ValidationError 발생 user.age = "invalid" # ValidationError 발생
``` ```
### Extra 필드 처리 ### Extra 필드 처리
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 | | `extra` | `'allow' \| 'ignore' \| 'forbid'` | `'ignore'` | 추가 필드 처리 방식 |
**값 설명:** **값 설명:**
- `'ignore'`: 추가 필드 무시 (기본값) - `'ignore'`: 추가 필드 무시 (기본값)
- `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장 - `'allow'`: 추가 필드 허용, `__pydantic_extra__`에 저장
- `'forbid'`: 추가 필드 입력 시 에러 발생 - `'forbid'`: 추가 필드 입력 시 에러 발생
**예시:** **예시:**
```python ```python
class AllowExtra(BaseModel): class AllowExtra(BaseModel):
model_config = ConfigDict(extra='allow') model_config = ConfigDict(extra='allow')
name: str name: str
data = AllowExtra(name="John", unknown_field="value") data = AllowExtra(name="John", unknown_field="value")
print(data.__pydantic_extra__) # {'unknown_field': 'value'} print(data.__pydantic_extra__) # {'unknown_field': 'value'}
class ForbidExtra(BaseModel): class ForbidExtra(BaseModel):
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')
name: str name: str
ForbidExtra(name="John", unknown="value") # ValidationError 발생 ForbidExtra(name="John", unknown="value") # ValidationError 발생
``` ```
### 불변성 (Immutability) ### 불변성 (Immutability)
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 | | `frozen` | `bool` | `False` | 모델을 불변(immutable)으로 만듦, `__hash__()` 구현 |
**예시:** **예시:**
```python ```python
class ImmutableUser(BaseModel): class ImmutableUser(BaseModel):
model_config = ConfigDict(frozen=True) model_config = ConfigDict(frozen=True)
name: str name: str
age: int age: int
user = ImmutableUser(name="John", age=30) user = ImmutableUser(name="John", age=30)
user.age = 31 # 에러 발생: Instance is frozen user.age = 31 # 에러 발생: Instance is frozen
# frozen=True이면 해시 가능 # frozen=True이면 해시 가능
users_set = {user} # 정상 작동 users_set = {user} # 정상 작동
``` ```
### 별칭 (Alias) 설정 ### 별칭 (Alias) 설정
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) | | `populate_by_name` | `bool` | `False` | 필드명과 별칭 모두로 값 설정 허용 (deprecated) |
| `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 | | `validate_by_alias` | `bool` | `True` | 별칭으로 필드 값 설정 허용 |
| `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 | | `validate_by_name` | `bool` | `False` | 별칭이 있어도 필드명으로 값 설정 허용 |
| `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 | | `serialize_by_alias` | `bool` | `False` | 직렬화 시 별칭 사용 |
| `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 | | `alias_generator` | `Callable[[str], str] \| None` | `None` | 별칭 자동 생성 함수 |
| `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 | | `loc_by_alias` | `bool` | `True` | 에러 위치에 별칭 사용 |
**예시:** **예시:**
```python ```python
from pydantic import Field from pydantic import Field
class APIResponse(BaseModel): class APIResponse(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
validate_by_alias=True, validate_by_alias=True,
validate_by_name=True, validate_by_name=True,
serialize_by_alias=True serialize_by_alias=True
) )
user_name: str = Field(alias="userName") user_name: str = Field(alias="userName")
# 둘 다 가능 # 둘 다 가능
response1 = APIResponse(userName="John") response1 = APIResponse(userName="John")
response2 = APIResponse(user_name="John") response2 = APIResponse(user_name="John")
print(response1.model_dump(by_alias=True)) # {"userName": "John"} print(response1.model_dump(by_alias=True)) # {"userName": "John"}
``` ```
**예시 - alias_generator:** **예시 - alias_generator:**
```python ```python
def to_camel(name: str) -> str: def to_camel(name: str) -> str:
parts = name.split('_') parts = name.split('_')
return parts[0] + ''.join(word.capitalize() for word in parts[1:]) return parts[0] + ''.join(word.capitalize() for word in parts[1:])
class CamelModel(BaseModel): class CamelModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
alias_generator=to_camel, alias_generator=to_camel,
serialize_by_alias=True serialize_by_alias=True
) )
first_name: str first_name: str
last_name: str last_name: str
data = CamelModel(firstName="John", lastName="Doe") data = CamelModel(firstName="John", lastName="Doe")
print(data.model_dump(by_alias=True)) print(data.model_dump(by_alias=True))
# {"firstName": "John", "lastName": "Doe"} # {"firstName": "John", "lastName": "Doe"}
``` ```
### JSON 스키마 ### JSON 스키마
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `title` | `str \| None` | `None` | JSON 스키마 타이틀 | | `title` | `str \| None` | `None` | JSON 스키마 타이틀 |
| `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 | | `json_schema_extra` | `dict \| Callable \| None` | `None` | JSON 스키마에 추가할 정보 |
| `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 | | `json_schema_serialization_defaults_required` | `bool` | `False` | 직렬화 스키마에서 기본값이 있는 필드도 required로 표시 |
| `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 | | `json_schema_mode_override` | `Literal['validation', 'serialization', None]` | `None` | JSON 스키마 모드 강제 지정 |
**예시 - json_schema_extra:** **예시 - json_schema_extra:**
```python ```python
class Product(BaseModel): class Product(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
title="상품 정보", title="상품 정보",
json_schema_extra={ json_schema_extra={
"example": { "example": {
"name": "노트북", "name": "노트북",
"price": 1500000 "price": 1500000
}, },
"description": "상품 데이터를 나타내는 모델" "description": "상품 데이터를 나타내는 모델"
} }
) )
name: str name: str
price: int price: int
# OpenAPI/Swagger 문서에 예시가 표시됨 # OpenAPI/Swagger 문서에 예시가 표시됨
``` ```
**예시 - Callable json_schema_extra:** **예시 - Callable json_schema_extra:**
```python ```python
def add_examples(schema: dict) -> dict: def add_examples(schema: dict) -> dict:
schema["examples"] = [ schema["examples"] = [
{"name": "예시1", "value": 100}, {"name": "예시1", "value": 100},
{"name": "예시2", "value": 200} {"name": "예시2", "value": 200}
] ]
return schema return schema
class DynamicSchema(BaseModel): class DynamicSchema(BaseModel):
model_config = ConfigDict(json_schema_extra=add_examples) model_config = ConfigDict(json_schema_extra=add_examples)
name: str name: str
value: int value: int
``` ```
### ORM/속성 모드 ### ORM/속성 모드
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) | | `from_attributes` | `bool` | `False` | 객체 속성에서 모델 생성 허용 (SQLAlchemy 등) |
**예시:** **예시:**
```python ```python
class UserORM: class UserORM:
def __init__(self, name: str, age: int): def __init__(self, name: str, age: int):
self.name = name self.name = name
self.age = age self.age = age
class UserModel(BaseModel): class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
name: str name: str
age: int age: int
orm_user = UserORM(name="John", age=30) orm_user = UserORM(name="John", age=30)
pydantic_user = UserModel.model_validate(orm_user) pydantic_user = UserModel.model_validate(orm_user)
print(pydantic_user) # name='John' age=30 print(pydantic_user) # name='John' age=30
``` ```
### Enum 처리 ### Enum 처리
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 | | `use_enum_values` | `bool` | `False` | Enum 대신 값(value)으로 저장 |
**예시:** **예시:**
```python ```python
from enum import Enum from enum import Enum
class Status(Enum): class Status(Enum):
ACTIVE = "active" ACTIVE = "active"
INACTIVE = "inactive" INACTIVE = "inactive"
class User(BaseModel): class User(BaseModel):
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
status: Status status: Status
user = User(status=Status.ACTIVE) user = User(status=Status.ACTIVE)
print(user.status) # "active" (문자열) print(user.status) # "active" (문자열)
print(type(user.status)) # <class 'str'> print(type(user.status)) # <class 'str'>
# use_enum_values=False (기본값)이면 # use_enum_values=False (기본값)이면
# user.status는 Status.ACTIVE (Enum 객체) # user.status는 Status.ACTIVE (Enum 객체)
``` ```
### 직렬화 설정 ### 직렬화 설정
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 | | `ser_json_timedelta` | `'iso8601' \| 'float'` | `'iso8601'` | timedelta JSON 직렬화 형식 |
| `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 | | `ser_json_bytes` | `'utf8' \| 'base64' \| 'hex'` | `'utf8'` | bytes JSON 직렬화 인코딩 |
| `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 | | `ser_json_inf_nan` | `'null' \| 'constants' \| 'strings'` | `'null'` | 무한대/NaN JSON 직렬화 형식 |
### 숫자/Float 설정 ### 숫자/Float 설정
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 | | `allow_inf_nan` | `bool` | `True` | float에서 무한대/NaN 허용 |
| `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 | | `coerce_numbers_to_str` | `bool` | `False` | 숫자를 문자열로 강제 변환 허용 |
### 기타 설정 ### 기타 설정
| 옵션 | 타입 | 기본값 | 설명 | | 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------| |------|------|--------|------|
| `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 | | `protected_namespaces` | `tuple[str, ...]` | `('model_',)` | 보호할 필드명 접두사 |
| `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 | | `hide_input_in_errors` | `bool` | `False` | 에러 메시지에서 입력값 숨김 |
| `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 | | `defer_build` | `bool` | `False` | validator/serializer 빌드 지연 |
| `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 | | `use_attribute_docstrings` | `bool` | `False` | 속성 docstring을 필드 설명으로 사용 |
| `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 | | `regex_engine` | `'rust-regex' \| 'python-re'` | `'rust-regex'` | 정규식 엔진 선택 |
| `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 | | `validation_error_cause` | `bool` | `False` | Python 예외를 에러 원인에 포함 |
## 설정 상속 ## 설정 상속
자식 모델은 부모 모델의 `model_config`를 상속받습니다. 자식 모델은 부모 모델의 `model_config`를 상속받습니다.
```python ```python
class ParentModel(BaseModel): class ParentModel(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
str_strip_whitespace=True, str_strip_whitespace=True,
extra='allow' extra='allow'
) )
name: str name: str
class ChildModel(ParentModel): class ChildModel(ParentModel):
model_config = ConfigDict( model_config = ConfigDict(
frozen=True # 부모 설정 + frozen=True frozen=True # 부모 설정 + frozen=True
) )
age: int age: int
# ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True # ChildModel은 str_strip_whitespace=True, extra='allow', frozen=True
``` ```
## FastAPI와 함께 사용 ## FastAPI와 함께 사용
FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다. FastAPI에서 요청/응답 스키마로 사용할 때 특히 유용합니다.
```python ```python
from fastapi import FastAPI from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
app = FastAPI() app = FastAPI()
class CreateUserRequest(BaseModel): class CreateUserRequest(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
str_strip_whitespace=True, str_strip_whitespace=True,
json_schema_extra={ json_schema_extra={
"example": { "example": {
"username": "johndoe", "username": "johndoe",
"email": "john@example.com" "email": "john@example.com"
} }
} }
) )
username: str = Field(..., min_length=3, max_length=50) username: str = Field(..., min_length=3, max_length=50)
email: str email: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
model_config = ConfigDict( model_config = ConfigDict(
from_attributes=True, # ORM 객체에서 변환 가능 from_attributes=True, # ORM 객체에서 변환 가능
serialize_by_alias=True serialize_by_alias=True
) )
id: int id: int
user_name: str = Field(alias="userName") user_name: str = Field(alias="userName")
@app.post("/users", response_model=UserResponse) @app.post("/users", response_model=UserResponse)
async def create_user(user: CreateUserRequest): async def create_user(user: CreateUserRequest):
# user.username은 자동으로 공백이 제거됨 # user.username은 자동으로 공백이 제거됨
... ...
``` ```
## 주의사항 ## 주의사항
1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요. 1. **v1에서 마이그레이션**: `class Config`는 deprecated입니다. `model_config = ConfigDict(...)`를 사용하세요.
2. **populate_by_name은 deprecated**: `validate_by_alias``validate_by_name`을 함께 사용하세요. 2. **populate_by_name은 deprecated**: `validate_by_alias``validate_by_name`을 함께 사용하세요.
3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요. 3. **json_encoders는 deprecated**: 커스텀 직렬화가 필요하면 `@field_serializer` 데코레이터를 사용하세요.
## 참고 자료 ## 참고 자료
- [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/) - [Pydantic Configuration API 공식 문서](https://docs.pydantic.dev/latest/api/config/)
- [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/) - [Pydantic Models 개념](https://docs.pydantic.dev/latest/concepts/models/)
- [Pydantic Migration Guide](https://docs.pydantic.dev/latest/migration/) - [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 app.core.database import Base
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
DateTime, DateTime,
Enum, Enum,
ForeignKey, ForeignKey,
Index, Index,
Integer, Integer,
PrimaryKeyConstraint, PrimaryKeyConstraint,
String, String,
func, func,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from starlette.authentication import BaseUser from starlette.authentication import BaseUser
class User(Base, BaseUser): class User(Base, BaseUser):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(
Integer, primary_key=True, nullable=False, autoincrement=True Integer, primary_key=True, nullable=False, autoincrement=True
) )
username: Mapped[str] = mapped_column( username: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True String(255), unique=True, nullable=False, index=True
) )
email: Mapped[str] = mapped_column( email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True String(255), unique=True, nullable=False, index=True
) )
hashed_password: Mapped[str] = mapped_column(String(60), nullable=False) hashed_password: Mapped[str] = mapped_column(String(60), nullable=False)
# age_level 컬럼을 Enum으로 정의 # age_level 컬럼을 Enum으로 정의
age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"] age_level_choices = ["10", "20", "30", "40", "50", "60", "70", "80"]
age_level: Mapped[str] = mapped_column( age_level: Mapped[str] = mapped_column(
Enum(*age_level_choices, name="age_level_enum"), Enum(*age_level_choices, name="age_level_enum"),
nullable=False, nullable=False,
default="10", default="10",
) )
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
# One-to-many relationship with Post (DynamicMapped + lazy="dynamic") # One-to-many relationship with Post (DynamicMapped + lazy="dynamic")
posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts") posts_user: Mapped[list["Post"]] = relationship("Post", back_populates="user_posts")
# # Many-to-many relationship with Group # # Many-to-many relationship with Group
# user_groups: DynamicMapped["UserGroupAssociation"] = relationship( # user_groups: DynamicMapped["UserGroupAssociation"] = relationship(
# "UserGroupAssociation", back_populates="user", lazy="dynamic" # "UserGroupAssociation", back_populates="user", lazy="dynamic"
# ) # )
# n:m 관계 (Group) 최적의 lazy 옵션: selectin # n:m 관계 (Group) 최적의 lazy 옵션: selectin
group_user: Mapped[list["Group"]] = relationship( group_user: Mapped[list["Group"]] = relationship(
"Group", "Group",
secondary="user_group_association", secondary="user_group_association",
back_populates="user_group", back_populates="user_group",
lazy="selectin", lazy="selectin",
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"id={self.id}, username={self.username}" return f"id={self.id}, username={self.username}"
@property @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
return self.is_active return self.is_active
@property @property
def display_name(self) -> str: def display_name(self) -> str:
return self.username return self.username
@property @property
def identity(self) -> str: def identity(self) -> str:
return self.username return self.username
# 1:N Relationship - Posts # 1:N Relationship - Posts
class Post(Base): class Post(Base):
__tablename__ = "posts" __tablename__ = "posts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
) )
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(String(10000), nullable=False) content: Mapped[str] = mapped_column(String(10000), nullable=False)
is_published: Mapped[bool] = mapped_column(Boolean, default=False) is_published: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column( updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now() DateTime, server_default=func.now(), onupdate=func.now()
) )
# tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함 # tags: Mapped[dict] = mapped_column(MutableDict.as_mutable(JSONB), default=[]) // sqlite 지원 안함
view_count: Mapped[int] = mapped_column(Integer, default=0) view_count: Mapped[int] = mapped_column(Integer, default=0)
# Many-to-one relationship with User (using dynamic loading) # Many-to-one relationship with User (using dynamic loading)
user_posts: Mapped["User"] = relationship("User", back_populates="posts_user") user_posts: Mapped["User"] = relationship("User", back_populates="posts_user")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})" return f"Post(id={self.id}, user_id={self.user_id}, title={self.title})"
__table_args__ = ( __table_args__ = (
Index("idx_posts_user_id", "user_id"), Index("idx_posts_user_id", "user_id"),
Index("idx_posts_created_at", "created_at"), Index("idx_posts_created_at", "created_at"),
Index( Index(
"idx_posts_user_id_created_at", "user_id", "created_at" "idx_posts_user_id_created_at", "user_id", "created_at"
), # Composite index ), # Composite index
) )
# N:M Relationship - Users and Groups # N:M Relationship - Users and Groups
# Association table for many-to-many relationship # Association table for many-to-many relationship
# N:M Association Table (중간 테이블) # N:M Association Table (중간 테이블)
class UserGroupAssociation(Base): class UserGroupAssociation(Base):
__tablename__ = "user_group_association" __tablename__ = "user_group_association"
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
) )
group_id: Mapped[int] = mapped_column( group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
) )
# # 관계 정의 # # 관계 정의
# user: Mapped["User"] = relationship("User", back_populates="user_groups") # user: Mapped["User"] = relationship("User", back_populates="user_groups")
# group: Mapped["Group"] = relationship("Group", back_populates="group_users") # group: Mapped["Group"] = relationship("Group", back_populates="group_users")
# # 복합 기본 키 설정 # # 복합 기본 키 설정
# 기본 키 설정을 위한 __table_args__ 추가 # 기본 키 설정을 위한 __table_args__ 추가
__table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),) __table_args__ = (PrimaryKeyConstraint("user_id", "group_id"),)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})" return f"UserGroupAssociation(user_id={self.user_id}, group_id={self.group_id})"
# Group 테이블 # Group 테이블
class Group(Base): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String(1000)) description: Mapped[str] = mapped_column(String(1000))
is_public: Mapped[bool] = mapped_column(Boolean, default=True) is_public: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[DateTime] = mapped_column( updated_at: Mapped[DateTime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now() DateTime, server_default=func.now(), onupdate=func.now()
) )
user_group: Mapped[list["User"]] = relationship( user_group: Mapped[list["User"]] = relationship(
"User", "User",
secondary="user_group_association", secondary="user_group_association",
back_populates="group_user", back_populates="group_user",
lazy="selectin", lazy="selectin",
) )
# Group을 만든 사용자와 관계 (일반적인 1:N 관계) # Group을 만든 사용자와 관계 (일반적인 1:N 관계)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Group(id={self.id}, name={self.name})" return f"Group(id={self.id}, name={self.name})"
__table_args__ = ( __table_args__ = (
Index("idx_groups_name", "name"), Index("idx_groups_name", "name"),
Index("idx_groups_is_public", "is_public"), Index("idx_groups_is_public", "is_public"),
Index("idx_groups_created_at", "created_at"), Index("idx_groups_created_at", "created_at"),
Index("idx_groups_composite", "is_public", "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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from scalar_fastapi import get_scalar_api_reference from scalar_fastapi import get_scalar_api_reference
from app.admin_manager import init_admin from app.admin_manager import init_admin
from app.core.common import lifespan from app.core.common import lifespan
from app.database.session import engine from app.database.session import engine
from app.home.api.routers.v1.home import router as home_router 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.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_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.video.api.routers.v1.video import router as video_router
from app.utils.cors import CustomCORSMiddleware from app.utils.cors import CustomCORSMiddleware
from config import prj_settings from config import prj_settings
app = FastAPI( app = FastAPI(
title=prj_settings.PROJECT_NAME, title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION, version=prj_settings.VERSION,
description=prj_settings.DESCRIPTION, description=prj_settings.DESCRIPTION,
lifespan=lifespan, lifespan=lifespan,
docs_url=None, # 기본 Swagger UI 비활성화 docs_url=None, # 기본 Swagger UI 비활성화
redoc_url=None, # 기본 ReDoc 비활성화 redoc_url=None, # 기본 ReDoc 비활성화
) )
init_admin(app, engine) init_admin(app, engine)
custom_cors_middleware = CustomCORSMiddleware(app) custom_cors_middleware = CustomCORSMiddleware(app)
custom_cors_middleware.configure_cors() custom_cors_middleware.configure_cors()
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
app.mount("/media", StaticFiles(directory="media"), name="media") app.mount("/media", StaticFiles(directory="media"), name="media")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
allow_credentials=True, allow_credentials=True,
max_age=-1, max_age=-1,
) )
@app.get("/docs", include_in_schema=False) @app.get("/docs", include_in_schema=False)
def get_scalar_docs(): def get_scalar_docs():
return get_scalar_api_reference( return get_scalar_api_reference(
openapi_url=app.openapi_url, openapi_url=app.openapi_url,
title="Scalar API", title="Scalar API",
) )
app.include_router(home_router) app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가 app.include_router(lyric_router) # Lyric API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가 app.include_router(song_router) # Song API 라우터 추가
app.include_router(video_router) # Video 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 copy
import requests import requests
CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823" CREATOMATE_API_KEY = "df9e4382d7e84fe790bf8a2168152be195d5a3568524ceb66ed989a2dea809f7d3065d6803b2e3dd9d02b5e5ec1c9823"
# ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/" # ACCOUNT_URL = "https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original/"
# Creatomate 템플릿 정보 전부 가져오기 # Creatomate 템플릿 정보 전부 가져오기
class Creatomate: class Creatomate:
base_url: str = "https://api.creatomate.com" base_url: str = "https://api.creatomate.com"
def __init__(self, api_key): def __init__(self, api_key):
self.api_key = api_key self.api_key = api_key
def get_all_templates_data(self) -> dict: def get_all_templates_data(self) -> dict:
url = Creatomate.base_url + "/v1/templates" url = Creatomate.base_url + "/v1/templates"
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
} }
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
return response.json() return response.json()
# Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기 # Creatomate 템플릿 ID 로부터 해당 템플릿 정보 가져오기
def get_one_template_data(self, template_id: str) -> dict: def get_one_template_data(self, template_id: str) -> dict:
url = Creatomate.base_url + f"/v1/templates/{template_id}" url = Creatomate.base_url + f"/v1/templates/{template_id}"
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
} }
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
return response.json() return response.json()
# 템플릿 정보 파싱하여 리소스 이름 추출하기 # 템플릿 정보 파싱하여 리소스 이름 추출하기
def parse_template_component_name(self, template_source: dict) -> dict: def parse_template_component_name(self, template_source: dict) -> dict:
def recursive_parse_component(element: dict) -> dict: def recursive_parse_component(element: dict) -> dict:
if "name" in element: if "name" in element:
result_element_name_type = {element["name"]: element["type"]} result_element_name_type = {element["name"]: element["type"]}
else: else:
result_element_name_type = {} result_element_name_type = {}
if element["type"] == "composition": if element["type"] == "composition":
minor_component_list = [ minor_component_list = [
recursive_parse_component(minor) for minor in element["elements"] 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 for minor_component in minor_component_list: ## WARNING : Same name component should shroud other component. be aware
result_element_name_type.update(minor_component) result_element_name_type.update(minor_component)
return result_element_name_type return result_element_name_type
result = {} result = {}
for result_element_dict in [ for result_element_dict in [
recursive_parse_component(component) for component in template_source recursive_parse_component(component) for component in template_source
]: ]:
result.update(result_element_dict) result.update(result_element_dict)
return result return result
# 템플릿 정보 이미지/가사/음악 리소스와 매핑하기 # 템플릿 정보 이미지/가사/음악 리소스와 매핑하기
# 이미지는 순차적으로 집어넣기 # 이미지는 순차적으로 집어넣기
# 가사는 개행마다 한 텍스트 삽입 # 가사는 개행마다 한 텍스트 삽입
# Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임) # Template에 audio-music 항목이 있어야 함. (추가된 템플릿 Cafe뿐임)
def template_connect_resource_blackbox( def template_connect_resource_blackbox(
self, template_id: str, image_url_list: list[str], lyric: str, music_url: str self, template_id: str, image_url_list: list[str], lyric: str, music_url: str
) -> dict: ) -> dict:
template_data = self.get_one_template_data(template_id) template_data = self.get_one_template_data(template_id)
template_component_data = self.parse_template_component_name( template_component_data = self.parse_template_component_name(
template_data["source"]["elements"] template_data["source"]["elements"]
) )
lyric.replace("\r", "") lyric.replace("\r", "")
lyric_splited = lyric.split("\n") lyric_splited = lyric.split("\n")
modifications = {} modifications = {}
for idx, (template_component_name, template_type) in enumerate( for idx, (template_component_name, template_type) in enumerate(
template_component_data.items() template_component_data.items()
): ):
match template_type: match template_type:
case "image": case "image":
modifications[template_component_name] = image_url_list[ modifications[template_component_name] = image_url_list[
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
modifications[template_component_name] = lyric_splited[ modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited) idx % len(lyric_splited)
] ]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
return modifications return modifications
def elements_connect_resource_blackbox( def elements_connect_resource_blackbox(
self, elements: list, image_url_list: list[str], lyric: str, music_url: str self, elements: list, image_url_list: list[str], lyric: str, music_url: str
) -> dict: ) -> dict:
template_component_data = self.parse_template_component_name(elements) template_component_data = self.parse_template_component_name(elements)
lyric.replace("\r", "") lyric.replace("\r", "")
lyric_splited = lyric.split("\n") lyric_splited = lyric.split("\n")
modifications = {} modifications = {}
for idx, (template_component_name, template_type) in enumerate( for idx, (template_component_name, template_type) in enumerate(
template_component_data.items() template_component_data.items()
): ):
match template_type: match template_type:
case "image": case "image":
modifications[template_component_name] = image_url_list[ modifications[template_component_name] = image_url_list[
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
modifications[template_component_name] = lyric_splited[ modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited) idx % len(lyric_splited)
] ]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
return modifications return modifications
def modify_element(self, elements: list, modification: dict): def modify_element(self, elements: list, modification: dict):
def recursive_modify(element: dict) -> dict: def recursive_modify(element: dict) -> dict:
if "name" in element: if "name" in element:
match element["type"]: match element["type"]:
case "image": case "image":
element["source"] = modification[element["name"]] element["source"] = modification[element["name"]]
case "audio": case "audio":
element["source"] = modification.get(element["name"], "") element["source"] = modification.get(element["name"], "")
case "video": case "video":
element["source"] = modification[element["name"]] element["source"] = modification[element["name"]]
case "text": case "text":
element["source"] = modification.get(element["name"], "") element["source"] = modification.get(element["name"], "")
case "composition": case "composition":
for minor in element["elements"]: for minor in element["elements"]:
recursive_modify(minor) recursive_modify(minor)
for minor in elements: for minor in elements:
recursive_modify(minor) recursive_modify(minor)
return elements return elements
# Creatomate에 생성 요청 # Creatomate에 생성 요청
# response에 요청 정보 있으니 풀링 필요 # response에 요청 정보 있으니 풀링 필요
def make_creatomate_call(self, template_id: str, modifications: dict): def make_creatomate_call(self, template_id: str, modifications: dict):
url = Creatomate.base_url + "/v2/renders" url = Creatomate.base_url + "/v2/renders"
data = {"template_id": template_id, "modifications": modifications} data = {"template_id": template_id, "modifications": modifications}
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
} }
response = requests.post(url, json=data, headers=headers) response = requests.post(url, json=data, headers=headers)
return response return response
# Creatomate에 생성 요청 without template # Creatomate에 생성 요청 without template
# response에 요청 정보 있으니 풀링 필요 # response에 요청 정보 있으니 풀링 필요
def make_creatomate_custom_call(self, source: str): def make_creatomate_custom_call(self, source: str):
url = Creatomate.base_url + "/v2/renders" url = Creatomate.base_url + "/v2/renders"
data = source data = source
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
} }
response = requests.post(url, json=data, headers=headers) response = requests.post(url, json=data, headers=headers)
return response return response
def calc_scene_duration(self, template: dict): def calc_scene_duration(self, template: dict):
total_template_duration = 0 total_template_duration = 0
for elem in template["source"]["elements"]: for elem in template["source"]["elements"]:
try: try:
if elem["type"] == "audio": if elem["type"] == "audio":
continue continue
total_template_duration += elem["duration"] total_template_duration += elem["duration"]
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
if animation["transition"]: if animation["transition"]:
total_template_duration -= animation["duration"] total_template_duration -= animation["duration"]
except: except:
print(elem) print(elem)
return total_template_duration return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float): def extend_template_duration(self, template: dict, target_duration: float):
template["duration"] = target_duration template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template) total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template) new_template = copy.deepcopy(template)
for elem in new_template["source"]["elements"]: for elem in new_template["source"]["elements"]:
try: try:
if elem["type"] == "audio": if elem["type"] == "audio":
continue continue
elem["duration"] = elem["duration"] * extend_rate elem["duration"] = elem["duration"] * extend_rate
if "animations" not in elem: if "animations" not in elem:
continue continue
for animation in elem["animations"]: for animation in elem["animations"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate animation["duration"] = animation["duration"] * extend_rate
except: except:
print(elem) print(elem)
return new_template return new_template
# Azure사용한 legacy 코드 원본 # Azure사용한 legacy 코드 원본
# def template_connect_resource_blackbox(template_id, user_idx, task_idx): # def template_connect_resource_blackbox(template_id, user_idx, task_idx):
# secret_client = get_keyvault_client() # secret_client = get_keyvault_client()
# account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value # account_url = secret_client.get_secret(BLOB_ACCOUNT_URL_KEY).value
# media_folder_path = f"{user_idx}/{task_idx}" # media_folder_path = f"{user_idx}/{task_idx}"
# lyric_path = f"{media_folder_path}/lyric.txt" # lyric_path = f"{media_folder_path}/lyric.txt"
# lyric = az_storage.az_storage_read_ado2_media(lyric_path).readall().decode('UTF-8') # 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) # 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] # image_list = [media.name for media in media_list if '/crawling-images/' in media.name]
# template_data = get_one_template_data(template_id) # template_data = get_one_template_data(template_id)
# template_component_data = parse_template_component_name(template_data['source']['elements']) # template_component_data = parse_template_component_name(template_data['source']['elements'])
# lyric.replace("\r", "") # lyric.replace("\r", "")
# lyric_splited = lyric.split("\n") # lyric_splited = lyric.split("\n")
# modifications = {} # modifications = {}
# for idx, (template_component_name, template_type) in enumerate(template_component_data.items()): # for idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
# match template_type: # match template_type:
# case 'image': # case 'image':
# modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}" # modifications[template_component_name] = f"{account_url}/{BLOB_CONTAINER_NAME}/{image_list[idx % len(image_list)]}"
# case 'text': # case 'text':
# modifications[template_component_name] = lyric_splited[idx % len(lyric_splited)] # 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" # modifications["audio-music"] = f"{account_url}/{BLOB_CONTAINER_NAME}/{BLOB_MEDIA_FOLDER}/{media_folder_path}/music_mureka.mp3"
# print(modifications) # print(modifications)
# return modifications # return modifications

View File

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

View File

@ -9,6 +9,7 @@ dependencies = [
"aiohttp>=3.13.2", "aiohttp>=3.13.2",
"aiomysql>=0.3.2", "aiomysql>=0.3.2",
"asyncmy>=0.2.10", "asyncmy>=0.2.10",
"beautifulsoup4>=4.14.3",
"fastapi-cli>=0.0.16", "fastapi-cli>=0.0.16",
"fastapi[standard]>=0.125.0", "fastapi[standard]>=0.125.0",
"openai>=2.13.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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2025.11.12"
@ -751,6 +764,7 @@ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiomysql" }, { name = "aiomysql" },
{ name = "asyncmy" }, { name = "asyncmy" },
{ name = "beautifulsoup4" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "fastapi-cli" }, { name = "fastapi-cli" },
{ name = "openai" }, { name = "openai" },
@ -775,6 +789,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiohttp", specifier = ">=3.13.2" },
{ name = "aiomysql", specifier = ">=0.3.2" }, { name = "aiomysql", specifier = ">=0.3.2" },
{ name = "asyncmy", specifier = ">=0.2.10" }, { name = "asyncmy", specifier = ">=0.2.10" },
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
{ name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "openai", specifier = ">=2.13.0" }, { 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" }, { 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]] [[package]]
name = "sqladmin" name = "sqladmin"
version = "0.22.0" version = "0.22.0"