remove endpoint at the video of get_videos
parent
c89e510c98
commit
7f0ae81351
|
|
@ -3,112 +3,6 @@ from typing import Literal, Optional
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||
|
||||
class AttributeInfo(BaseModel):
|
||||
"""음악 속성 정보"""
|
||||
|
||||
genre: str = Field(..., description="음악 장르")
|
||||
vocal: str = Field(..., description="보컬 스타일")
|
||||
tempo: str = Field(..., description="템포")
|
||||
mood: str = Field(..., description="분위기")
|
||||
|
||||
|
||||
class GenerateRequestImg(BaseModel):
|
||||
"""이미지 URL 스키마"""
|
||||
|
||||
url: str = Field(..., description="이미지 URL")
|
||||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class GenerateRequestInfo(BaseModel):
|
||||
"""생성 요청 정보 스키마 (이미지 제외)"""
|
||||
|
||||
customer_name: str = Field(..., description="고객명/가게명")
|
||||
region: str = Field(..., description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
|
||||
language: str = Field(
|
||||
default="Korean",
|
||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(GenerateRequestInfo):
|
||||
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
|
||||
|
||||
이미지 없이 프로젝트 정보만 전달합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GenerateUrlsRequest(GenerateRequestInfo):
|
||||
"""URL 기반 생성 요청 스키마 (JSON body)
|
||||
|
||||
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"attribute": {
|
||||
"genre": "K-Pop",
|
||||
"vocal": "Raspy",
|
||||
"tempo": "110 BPM",
|
||||
"mood": "happy",
|
||||
},
|
||||
"language": "Korean",
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: list[GenerateRequestImg] = Field(
|
||||
..., description="이미지 URL 목록", min_length=1
|
||||
)
|
||||
|
||||
|
||||
class GenerateUploadResponse(BaseModel):
|
||||
"""파일 업로드 기반 생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
"""생성 응답 스키마"""
|
||||
|
||||
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
|
||||
status: Literal["processing", "completed", "failed"] = Field(
|
||||
..., description="작업 상태"
|
||||
)
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
|
||||
|
||||
class CrawlingRequest(BaseModel):
|
||||
"""크롤링 요청 스키마"""
|
||||
|
||||
|
|
@ -371,29 +265,6 @@ class ImageUrlItem(BaseModel):
|
|||
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
|
||||
|
||||
|
||||
class ImageUploadRequest(BaseModel):
|
||||
"""이미지 업로드 요청 스키마 (JSON body 부분)
|
||||
|
||||
URL 이미지 목록을 전달합니다.
|
||||
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"images": [
|
||||
{"url": "https://example.com/images/image_001.jpg"},
|
||||
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
images: Optional[list[ImageUrlItem]] = Field(
|
||||
None, description="외부 이미지 URL 목록"
|
||||
)
|
||||
|
||||
|
||||
class ImageUploadResultItem(BaseModel):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SNS API Schemas
|
|||
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
|
@ -98,20 +98,6 @@ class Media(BaseModel):
|
|||
children: Optional[list["Media"]] = None
|
||||
|
||||
|
||||
class MediaList(BaseModel):
|
||||
"""미디어 목록 응답"""
|
||||
|
||||
data: list[Media] = Field(default_factory=list)
|
||||
paging: Optional[dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def next_cursor(self) -> Optional[str]:
|
||||
"""다음 페이지 커서"""
|
||||
if self.paging and "cursors" in self.paging:
|
||||
return self.paging["cursors"].get("after")
|
||||
return None
|
||||
|
||||
|
||||
class MediaContainer(BaseModel):
|
||||
"""미디어 컨테이너 상태"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel):
|
|||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class PollingSongRequest(BaseModel):
|
||||
"""노래 생성 상태 조회 요청 스키마 (Legacy)
|
||||
|
||||
Note:
|
||||
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
|
||||
|
||||
Example Request:
|
||||
{
|
||||
"task_id": "abc123..."
|
||||
}
|
||||
"""
|
||||
|
||||
task_id: str = Field(..., description="Suno 작업 ID")
|
||||
|
||||
|
||||
class SongClipData(BaseModel):
|
||||
"""생성된 노래 클립 정보"""
|
||||
|
||||
|
|
@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel):
|
|||
song_result_url: Optional[str] = Field(
|
||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dataclass Schemas (Legacy)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreData:
|
||||
id: int
|
||||
created_at: datetime
|
||||
store_name: str
|
||||
store_category: str | None = None
|
||||
store_region: str | None = None
|
||||
store_address: str | None = None
|
||||
store_phone_number: str | None = None
|
||||
store_info: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttributeData:
|
||||
id: int
|
||||
attr_category: str
|
||||
attr_value: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongSampleData:
|
||||
id: int
|
||||
ai: str
|
||||
ai_model: str
|
||||
sample_song: str
|
||||
season: str | None = None
|
||||
num_of_people: int | None = None
|
||||
people_category: str | None = None
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplateData:
|
||||
id: int
|
||||
prompt: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SongFormData:
|
||||
store_name: str
|
||||
store_id: str
|
||||
prompts: str
|
||||
attributes: Dict[str, str] = field(default_factory=dict)
|
||||
attributes_str: str = ""
|
||||
lyrics_ids: List[int] = field(default_factory=list)
|
||||
llm_model: str = "gpt-5-mini"
|
||||
|
||||
@classmethod
|
||||
async def from_form(cls, request: Request):
|
||||
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
|
||||
form_data = await request.form()
|
||||
|
||||
# 고정 필드명들
|
||||
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
|
||||
|
||||
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
|
||||
lyrics_ids = []
|
||||
attributes = {}
|
||||
|
||||
for key, value in form_data.items():
|
||||
if key.startswith("lyrics-"):
|
||||
lyrics_id = key.split("-")[1]
|
||||
lyrics_ids.append(int(lyrics_id))
|
||||
elif key not in fixed_keys:
|
||||
attributes[key] = value
|
||||
|
||||
# attributes를 문자열로 변환
|
||||
attributes_str = (
|
||||
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
|
||||
if attributes
|
||||
else ""
|
||||
)
|
||||
|
||||
return cls(
|
||||
store_name=form_data.get("store_info_name", ""),
|
||||
store_id=form_data.get("store_id", ""),
|
||||
attributes=attributes,
|
||||
attributes_str=attributes_str,
|
||||
lyrics_ids=lyrics_ids,
|
||||
llm_model=form_data.get("llm_model", "gpt-5-mini"),
|
||||
prompts=form_data.get("prompts", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ logger = logging.getLogger(__name__)
|
|||
from app.user.dependencies import get_current_user
|
||||
from app.user.models import RefreshToken, User
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
KakaoLoginResponse,
|
||||
LoginResponse,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoCodeRequest,
|
||||
KakaoLoginResponse,
|
||||
KakaoTokenResponse,
|
||||
|
|
@ -12,7 +11,6 @@ from app.user.schemas.user_schema import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"AccessTokenResponse",
|
||||
"KakaoCodeRequest",
|
||||
"KakaoLoginResponse",
|
||||
"KakaoTokenResponse",
|
||||
|
|
|
|||
|
|
@ -64,24 +64,6 @@ class TokenResponse(BaseModel):
|
|||
}
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
"""액세스 토큰 갱신 응답"""
|
||||
|
||||
access_token: str = Field(..., description="액세스 토큰")
|
||||
token_type: str = Field(default="Bearer", description="토큰 타입")
|
||||
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""토큰 갱신 요청"""
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ class AdminRequiredError(AuthException):
|
|||
from app.user.models import RefreshToken, User
|
||||
from app.utils.common import generate_uuid
|
||||
from app.user.schemas.user_schema import (
|
||||
AccessTokenResponse,
|
||||
KakaoUserInfo,
|
||||
LoginResponse,
|
||||
TokenResponse,
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
# Input 정의
|
||||
class YTUploadPromptInput(BaseModel):
|
||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||
language : str= Field(..., description = "영상 언어")
|
||||
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||
|
||||
# Output 정의
|
||||
class YTUploadPromptOutput(BaseModel):
|
||||
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
||||
description: str = Field(..., description = "유튜브 영상 설명 - SEO/AEO 최적화")
|
||||
|
||||
|
|
@ -7,7 +7,6 @@ Video API Router
|
|||
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
|
||||
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
|
||||
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
|
||||
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
|
||||
|
||||
사용 예시:
|
||||
from app.video.api.routers.v1.video import router
|
||||
|
|
@ -18,11 +17,10 @@ import json
|
|||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
||||
from app.user.dependencies.auth import get_current_user
|
||||
from app.user.models import User
|
||||
from app.home.models import Image, Project
|
||||
|
|
@ -30,13 +28,11 @@ from app.lyric.models import Lyric
|
|||
from app.song.models import Song, SongTimestamp
|
||||
from app.utils.creatomate import CreatomateService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.video.models import Video
|
||||
from app.video.schemas.video_schema import (
|
||||
DownloadVideoResponse,
|
||||
GenerateVideoResponse,
|
||||
PollingVideoResponse,
|
||||
VideoListItem,
|
||||
VideoRenderData,
|
||||
)
|
||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||
|
|
@ -738,126 +734,3 @@ async def download_video(
|
|||
message="영상 다운로드 조회에 실패했습니다.",
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
summary="생성된 영상 목록 조회",
|
||||
description="""
|
||||
완료된 영상 목록을 페이지네이션하여 조회합니다.
|
||||
|
||||
## 인증
|
||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
||||
|
||||
## 쿼리 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
|
||||
- **total**: 전체 데이터 수
|
||||
- **page**: 현재 페이지
|
||||
- **page_size**: 페이지당 데이터 수
|
||||
- **total_pages**: 전체 페이지 수
|
||||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시 (cURL)
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
|
||||
-H "Authorization: Bearer {access_token}"
|
||||
```
|
||||
|
||||
## 참고
|
||||
- status가 'completed'인 영상만 반환됩니다.
|
||||
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
|
||||
- created_at 기준 내림차순 정렬됩니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[VideoListItem],
|
||||
responses={
|
||||
200: {"description": "영상 목록 조회 성공"},
|
||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||
500: {"description": "조회 실패"},
|
||||
},
|
||||
)
|
||||
async def get_videos(
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[VideoListItem]:
|
||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
||||
logger.info(
|
||||
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
# 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만)
|
||||
subquery = (
|
||||
select(func.max(Video.id).label("max_id"))
|
||||
.where(Video.status == "completed")
|
||||
.group_by(Video.task_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
||||
count_query = select(func.count()).select_from(subquery)
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
|
||||
query = (
|
||||
select(Video)
|
||||
.where(Video.id.in_(select(subquery.c.max_id)))
|
||||
.order_by(Video.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
videos = result.scalars().all()
|
||||
|
||||
# Project 정보 일괄 조회 (N+1 문제 해결)
|
||||
project_ids = [v.project_id for v in videos if v.project_id]
|
||||
projects_map: dict = {}
|
||||
if project_ids:
|
||||
projects_result = await session.execute(
|
||||
select(Project).where(Project.id.in_(project_ids))
|
||||
)
|
||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
||||
|
||||
# VideoListItem으로 변환
|
||||
items = []
|
||||
for video in videos:
|
||||
project = projects_map.get(video.project_id)
|
||||
|
||||
item = VideoListItem(
|
||||
video_id=video.id,
|
||||
store_name=project.store_name if project else None,
|
||||
region=project.region if project else None,
|
||||
task_id=video.task_id,
|
||||
result_movie_url=video.result_movie_url,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
response = PaginatedResponse.create(
|
||||
items=items,
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
|
||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_videos] EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue